Feature Card
Marketing card highlighting a feature.
Installation
$
npx @309-thingspire/ui@latest add feature-cardUsage
import { FeatureCard } from "@/components/feature-card/feature-card"<FeatureCard />Examples
Live preview rendered from FeatureCard.preview.tsx. Switch to the Code tab to view the underlying component source.
Loading preview…
import React from 'react';
import type { CSSProperties } from 'react';
import { border, colors, radius, shadows, spacing, typography } from '../../style-tokens';
import type {
FeatureCardAction,
FeatureCardAlignment,
FeatureCardProps,
FeatureCardType,
} from './FeatureCard.types';
type TypographyToken = {
fontFamily: string;
fontSize: number;
fontWeight: number;
lineHeight: number;
letterSpacing: number;
};
type GradientToken = {
rotation: number;
stops: ReadonlyArray<{
position: number;
color: string;
}>;
};
const palette = colors.primitive.palette;
const textTokens = colors.semantic.theme.text.base;
const gradientSolid04 = colors.gradients.solid['04'] as GradientToken;
const SIZE_260 = spacing.primitive['256'] + spacing.scale['4'];
const SIZE_280 = spacing.scale['288'] - spacing.scale['8'];
function getTypographyStyle(token: TypographyToken): CSSProperties {
return {
fontFamily: token.fontFamily,
fontSize: token.fontSize,
fontWeight: token.fontWeight,
lineHeight: `${token.lineHeight}px`,
letterSpacing: `${token.letterSpacing}px`,
};
}
function toLinearGradient(token: GradientToken): string {
const stops = token.stops.map((stop) => `${stop.color} ${stop.position * 100}%`).join(', ');
return `linear-gradient(${token.rotation}deg, ${stops})`;
}
function ActionButton({
action,
appearance,
}: {
action: FeatureCardAction;
appearance: 'primary' | 'secondary';
}) {
const isPrimary = appearance === 'primary';
return (
<button
type="button"
onClick={action.onClick}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.scale['2'],
paddingInline: spacing.scale['10'],
paddingBlock: spacing.scale['6'],
borderStyle: 'solid',
borderWidth: border.width['1'],
borderColor: isPrimary ? palette.gray['13'] : palette.gray['3'],
borderRadius: radius.scale.lg,
backgroundColor: isPrimary ? palette.gray['13'] : palette.base.white,
color: isPrimary ? palette.base.white : textTokens.staticDark,
boxShadow: shadows.elevation.xs.css,
cursor: 'pointer',
}}
>
<span
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
paddingInline: spacing.scale['4'],
paddingBlock: spacing.scale['0'],
...getTypographyStyle(typography.scale.captionL.medium),
textAlign: 'center',
whiteSpace: 'nowrap',
}}
>
{action.label}
</span>
</button>
);
}
function DefaultCustomSlot() {
return (
<div
style={{
width: '100%',
minHeight: SIZE_280,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderStyle: 'dashed',
borderWidth: border.width['1'],
borderColor: palette.purple['8'],
borderRadius: radius.scale.xl,
backgroundColor: palette.purple['2'],
overflow: 'hidden',
boxSizing: 'border-box',
}}
>
<span
style={{
...getTypographyStyle(typography.scale.h6.medium),
color: palette.purple['11'],
textAlign: 'center',
whiteSpace: 'nowrap',
}}
>
Slot
</span>
</div>
);
}
function resolveAlignmentOrder(alignment: FeatureCardAlignment) {
return alignment === 'imageFirst' ? (['image', 'content'] as const) : (['content', 'image'] as const);
}
function ImageBlock({ flat, imageSlot }: { flat: boolean; imageSlot: FeatureCardProps['imageSlot'] }) {
return (
<div
style={{
flex: '1 0 0',
minWidth: spacing.scale['320'],
minHeight: SIZE_260,
borderRadius: flat ? radius.scale.xxl : radius.scale['0'],
backgroundImage: toLinearGradient(gradientSolid04),
backgroundColor: gradientSolid04.stops[0]?.color ?? palette.gray['1'],
overflow: 'hidden',
}}
>
{imageSlot}
</div>
);
}
function ContentBlock({
type,
showBadge,
badgeLabel,
headline,
description,
showActions,
primaryAction,
secondaryAction,
}: {
type: FeatureCardType;
showBadge: boolean;
badgeLabel: string;
headline: string;
description: string;
showActions: boolean;
primaryAction: FeatureCardAction;
secondaryAction: FeatureCardAction;
}) {
const isFlat = type === 'flat';
return (
<div
style={{
position: 'relative',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'flex-start',
gap: spacing.scale['20'],
flex: '1 0 0',
minWidth: spacing.scale['320'],
padding: isFlat ? spacing.scale['0'] : spacing.scale['24'],
}}
>
{showBadge ? (
<div
style={{
width: '100%',
display: 'flex',
flexWrap: 'wrap',
alignItems: 'flex-start',
gap: spacing.scale['8'],
}}
>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
paddingInline: spacing.scale['4'],
paddingBlock: spacing.scale['2'],
borderStyle: 'solid',
borderWidth: border.width['1'],
borderColor: palette.gray['2a'],
borderRadius: radius.scale.full,
backgroundColor: palette.green['2'],
}}
>
<span
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
paddingInline: spacing.scale['4'],
paddingBlock: spacing.scale['0'],
color: palette.green['11'],
...getTypographyStyle(typography.scale.captionL.medium),
textAlign: 'center',
whiteSpace: 'nowrap',
}}
>
{badgeLabel}
</span>
</div>
</div>
) : null}
<div
style={{
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: spacing.scale['8'],
padding: spacing.scale['0'],
}}
>
<h3
style={{
margin: spacing.scale['0'],
width: '100%',
color: textTokens.staticDark,
...getTypographyStyle(typography.scale.h6.semiBold),
}}
>
{headline}
</h3>
<p
style={{
margin: spacing.scale['0'],
width: '100%',
color: textTokens.staticDarkSecondary,
...getTypographyStyle(typography.scale.bodyS.regular),
}}
>
{description}
</p>
</div>
{showActions ? (
<div
style={{
width: '100%',
display: 'flex',
alignItems: 'flex-start',
gap: spacing.scale['8'],
flexWrap: 'wrap',
}}
>
<ActionButton action={primaryAction} appearance="primary" />
<ActionButton action={secondaryAction} appearance="secondary" />
</div>
) : null}
</div>
);
}
export function FeatureCard({
type = 'elevated',
alignment = 'imageFirst',
badgeLabel = 'Badge',
headline = 'Medium length headline',
description = 'Design better and spend less time without restricting creative freedom.',
primaryAction = { label: 'Button' },
secondaryAction = { label: 'Button' },
showBadge = true,
showActions = true,
imageSlot,
customSlot,
className,
style,
...props
}: FeatureCardProps) {
const rootStyle: CSSProperties = {
width: spacing.scale['360'],
minWidth: spacing.scale['320'],
boxSizing: 'border-box',
};
if (type === 'custom') {
return (
<article
className={className}
style={{
...rootStyle,
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: spacing.scale['0'],
padding: spacing.scale['24'],
borderStyle: 'solid',
borderWidth: border.width['1'],
borderColor: palette.gray['3'],
borderRadius: radius.scale.xxl,
backgroundColor: palette.base.white,
boxShadow: shadows.elevation.xs.css,
...style,
}}
{...props}
>
{customSlot ?? <DefaultCustomSlot />}
</article>
);
}
const isFlat = type === 'flat';
const [first, second] = resolveAlignmentOrder(alignment);
const image = <ImageBlock flat={isFlat} imageSlot={imageSlot} />;
const content = (
<ContentBlock
type={type}
showBadge={showBadge}
badgeLabel={badgeLabel}
headline={headline}
description={description}
showActions={showActions}
primaryAction={primaryAction}
secondaryAction={secondaryAction}
/>
);
return (
<article
className={className}
style={{
...rootStyle,
position: 'relative',
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: isFlat ? spacing.scale['24'] : spacing.scale['0'],
padding: spacing.scale['0'],
overflow: 'hidden',
borderStyle: 'solid',
borderWidth: isFlat ? border.width['0'] : border.width['1'],
borderColor: isFlat ? palette.base.transparent : palette.gray['2a'],
borderRadius: isFlat ? radius.scale['0'] : radius.scale.xxl,
backgroundColor: isFlat ? palette.base.transparent : palette.base.white,
boxShadow: shadows.elevation.xs.css,
...style,
}}
{...props}
>
{first === 'image' ? image : content}
{second === 'content' ? content : image}
</article>
);
}
API Reference
Props 문서 준비 중입니다. (component-spec.json 추가 시 표시됩니다.)