fluid
uicardsource: ok · hit
Card
shadcn's compositional card (Card, CardHeader, CardTitle, CardDescription, CardAction, CardContent, CardFooter) with the Fluid Functionalism layer: transparent substrate, weight-animated title, media/logo/feature/image slots, and a CardGroup that owns stacked, inline, and grid layouts plus 2-D fluid-hover highlighting.
npx shadcn add "https://www.fluidfunctionalism.com/r/card.json"free
Where it goes
navbarfooterfeature-grid
Visual style
playful
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationframer-motion
Registry dependencies
utilshttps://www.fluidfunctionalism.com/r/springs.jsonhttps://www.fluidfunctionalism.com/r/font-weight.jsonhttps://www.fluidfunctionalism.com/r/shape-context.jsonhttps://www.fluidfunctionalism.com/r/icon-context.jsonhttps://www.fluidfunctionalism.com/r/use-fluid-hover.jsonhttps://www.fluidfunctionalism.com/r/size-context.jsonhttps://www.fluidfunctionalism.com/r/tokens.jsonhttps://www.fluidfunctionalism.com/r/type-scale.json
Files1 · source cached 10/9/2026, 11:29:05 AM
registry/default/card.tsx
"use client";
import {
Children,
cloneElement,
createContext,
forwardRef,
isValidElement,
useContext,
useMemo,
useRef,
type HTMLAttributes,
type ReactElement,
type ReactNode,
} from "react";
import Link from "next/link";
import { cn } from "@/lib/utils";
import { fontWeights } from "@/lib/font-weight";
import { useShape } from "@/lib/shape-context";
import { SizeProvider, useSize, typeClass, type SizeVariant } from "@/lib/size-context";
import { useIcons, type IconComponent } from "@/lib/icon-context";
import { useFluidHover, useRegisterFluidHoverItem } from "@/hooks/use-fluid-hover";
import { FluidHoverHighlight } from "@/components/ui/fluid-hover-highlight";
// ---------------------------------------------------------------------------
// Card is shadcn/ui's compositional card — the same parts and `data-slot`
// contract (Card, CardHeader, CardTitle, CardDescription, CardAction,
// CardContent, CardFooter) — with the Fluid Functionalism layer on top:
// design tokens, a weight-animated title, and a sibling CardGroup that owns
// layout (stacked list, inline rows, or grid) plus the magnetic fluid hover
// highlight that previews where a click will land.
//
// Unlike stock shadcn, the surface is transparent and borderless by default:
// cards inherit whatever substrate their parent provides (see the Surfaces
// system) and lean on hairline dividers / the fluid hover highlight rather than
// a drawn frame. A Card renders fine on its own; inside a CardGroup it
// registers itself so the group's highlight can find it.
// ---------------------------------------------------------------------------
type CardOrientation = "card" | "inline";
type CardBorder = "none" | "outlined";
// ── Group context ────────────────────────────────────────
interface CardGroupContextValue {
registerItem: (index: number, element: HTMLElement | null) => void;
activeIndex: number | null;
/** Index of the persistently-selected card, or -1. Its neighbours drop the
* hairline that would otherwise cut across the selection fill. */
selectedIndex: number;
orientation: CardOrientation;
columns: number;
count: number;
/** Individual cards carry their own border/tile shape (separated grids). */
separated: boolean;
/** Inner hairline dividers are drawn between adjacent cards. */
divided: boolean;
outlined: boolean;
}
const CardGroupContext = createContext<CardGroupContextValue | null>(null);
// ── Per-card context ─────────────────────────────────────
// Lets the compositional parts (title, header, footer…) adapt to the card
// they're inside without threading props: the title reads `emphasized` to
// animate its weight; parts read `orientation` to switch padding/flow.
interface CardContextValue {
emphasized: boolean;
orientation: CardOrientation;
clickable: boolean;
/** An inline card holding a full CardImage centres its text + actions in a
* column beside the image, so the footer drops below the text (hugged and
* vertically centred) instead of trailing to the right. */
hasImage: boolean;
}
const CardContext = createContext<CardContextValue>({
emphasized: false,
orientation: "card",
clickable: false,
hasImage: false,
});
// ── CardGroup ────────────────────────────────────────────
interface CardGroupProps extends Omit<HTMLAttributes<HTMLDivElement>, "onDrag"> {
/** How each card lays its own content out.
* "card" — stacked vertically (media/header on top). "inline" — a
* horizontal row (leading media, trailing footer), like a Table row.
* @default "card" */
orientation?: CardOrientation;
/** Number of grid columns. >1 enables 2-D fluid hover across rows and columns.
* @default 1 */
columns?: number;
/** "none" — borderless (default), separated only by subtle dividers.
* "outlined" — draws a border: one shared frame when grouped, or one per
* card when `separated`. @default "none" */
border?: CardBorder;
/** Split the group into individually-shaped cards with a gap between them
* (a grid of tiles) instead of one continuous divided block. @default false */
separated?: boolean;
/** Enable the magnetic fluid-hover highlight. @default true */
fluidHover?: boolean;
}
const CardGroup = forwardRef<HTMLDivElement, CardGroupProps>(
(
{
orientation = "card",
columns = 1,
border = "none",
separated = false,
fluidHover = true,
className,
children,
...props
},
ref
) => {
const containerRef = useRef<HTMLDivElement>(null);
const shape = useShape();
// >1 column wraps into a grid, where nearest-item must be resolved in two
// dimensions; a single column is a plain vertical list.
const axis = columns > 1 ? "xy" : "y";
// A card grid has generous whitespace, so a gap click only routes when it
// lands within a hand's width of the highlighted card.
const hover = useFluidHover(containerRef, { axis, gapClick: { maxDistance: 16 } });
const {
activeIndex,
handlers,
registerItem,
} = hover;
// Assign each valid child a stable fluid hover index so callers never thread
// one through by hand (Table asks for it; here the group owns it).
const childArray = Children.toArray(children).filter(isValidElement);
const count = childArray.length;
const indexed = childArray.map((child, i) =>
cloneElement(child as ReactElement<{ index?: number }>, { index: i })
);
// Which card is selected — so its neighbours can drop the divider that
// would otherwise slice through the selection fill.
const selectedIndex = childArray.findIndex(
(child) => (child.props as { selected?: boolean }).selected
);
const outlined = border === "outlined";
const divided = !separated;
const contextValue = useMemo<CardGroupContextValue>(
() => ({
registerItem,
activeIndex,
selectedIndex,
orientation,
columns,
count,
separated,
divided,
outlined,
}),
[
registerItem,
activeIndex,
selectedIndex,
orientation,
columns,
count,
separated,
divided,
outlined,
]
);
return (
<CardGroupContext.Provider value={contextValue}>
<div
ref={(node) => {
(containerRef as React.MutableRefObject<HTMLDivElement | null>).current =
node;
if (typeof ref === "function") ref(node);
else if (ref)
(ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
}}
{...props}
data-slot="card-group"
data-orientation={orientation}
className={cn(
"relative grid",
// A shared frame clips the highlight + dividers to its rounded
// corners; separated tiles clip themselves.
outlined && !separated && `border border-border/60 overflow-hidden ${shape.container}`,
separated ? "gap-2" : "gap-0",
className
)}
style={{
gridTemplateColumns: `repeat(${Math.max(1, columns)}, minmax(0, 1fr))`,
}}
onMouseEnter={fluidHover ? handlers.onMouseEnter : undefined}
onMouseMove={fluidHover ? handlers.onMouseMove : undefined}
onMouseLeave={fluidHover ? handlers.onMouseLeave : undefined}
onClick={fluidHover ? handlers.onClick : undefined}
>
{/* Fluid hover highlight — a single magnetic layer that springs to the
card nearest the cursor, previewing where a click will land. */}
<FluidHoverHighlight
hover={hover}
hidden={!fluidHover}
className={cn("z-0", shape.container)}
/>
{indexed}
</div>
</CardGroupContext.Provider>
);
}
);
CardGroup.displayName = "CardGroup";
// ── Card ─────────────────────────────────────────────────
interface CardProps extends Omit<HTMLAttributes<HTMLDivElement>, "onClick"> {
/** Makes the whole card an interactive target; fluid hover previews it.
* Renders a stretched link when `href` is set, else a stretched button. */
onClick?: () => void;
href?: string;
external?: boolean;
/** Accessible name for the stretched link/button when the whole card is
* clickable (the card's visible title isn't wired up automatically). */
label?: string;
/** Persistent selected state, on top of the transient fluid hover. */
selected?: boolean;
disabled?: boolean;
/** Shows a dismiss (✕) button in the corner. */
dismissible?: boolean;
/** Keep the dismiss control hidden until the card is hovered or holds
* focus — the default, so a permanent ✕ never competes with the content.
* Pass false for an always-visible control. @default true */
dismissOnHover?: boolean;
onDismiss?: () => void;
/** Pins the card to one step of the size ladder (see /docs/sizes) — compact
* tightens type and padding. Omitted, it follows the surrounding
* SizeProvider. */
size?: SizeVariant;
/** Injected by CardGroup — do not set by hand. */
index?: number;
}
const Card = forwardRef<HTMLDivElement, CardProps>(
(
{
onClick,
href,
external,
label,
selected = false,
disabled = false,
dismissible = false,
dismissOnHover = true,
onDismiss,
size,
index,
className,
children,
...props
},
ref
) => {
const internalRef = useRef<HTMLDivElement>(null);
const shape = useShape();
// Resolve against the explicit prop first so this card's own padding and
// gaps agree with the SizeProvider it renders for its children.
const sizeClasses = useSize(size);
const compact = sizeClasses.variant === "compact";
const group = useContext(CardGroupContext);
const icons = useIcons();
const XIcon = icons.x;
const orientation = group?.orientation ?? "card";
const columns = group?.columns ?? 1;
const count = group?.count ?? 1;
const separated = group?.separated ?? true;
const divided = group?.divided ?? false;
const outlined = group?.outlined ?? false;
const activeIndex = group?.activeIndex ?? null;
const selectedIndex = group?.selectedIndex ?? -1;
// Depend on the stable registerItem callback, not the whole group context —
// the context object's identity changes on every hover/selection frame,
// which would otherwise re-register every card each frame.
// Only a card that can be clicked joins the highlight: a highlight on an
// informational card would promise a click that has nowhere to land.
const registerItem = href || onClick ? group?.registerItem : undefined;
useRegisterFluidHoverItem(registerItem, index, internalRef);
// Divider geometry: draw a hairline toward the neighbour below / to the
// right, but drop it next to the active OR selected card so the highlight
// and selection fill read clean (the same trick Table uses on row borders).
const col = index !== undefined ? index % columns : 0;
const hasBelow = index !== undefined && index + columns < count;
const hasRight =
index !== undefined && col < columns - 1 && index + 1 < count;
const self = index ?? -1;
const touchesBelow = (i: number) => i === self || i === self + columns;
const touchesRight = (i: number) => i === self || i === self + 1;
const showBottom =
divided &&
hasBelow &&
!(touchesBelow(activeIndex ?? -1) || touchesBelow(selectedIndex));
const showRight =
divided &&
hasRight &&
!(touchesRight(activeIndex ?? -1) || touchesRight(selectedIndex));
const isInline = orientation === "inline";
// An inline card with a full-bleed image reflows so its actions stack under
// the text (see the body wrapper below + CardHeader/CardFooter). Match the
// image child by identity OR displayName so detection and the split below
// agree even when module identity drifts (e.g. HMR duplication).
const isCardImage = (child: ReactNode) =>
isValidElement(child) &&
(child.type === CardImage ||
(child.type as { displayName?: string })?.displayName === "CardImage");
const hasImage = Children.toArray(children).some(isCardImage);
const inlineImage = isInline && hasImage;
const clickable = !!href || !!onClick;
// Title weight follows the persistent selected state only — fluid hover
// previews via the highlight fill, not by bolding the label.
const emphasized = selected;
// A standalone card (no group) is its own tile — always rounded + clipped.
// Inside a group, a separated tile carries its own rounding + clip only when
// it draws a visible frame; a borderless separated tile has no surface to
// hug, so it stays unclipped and its media reads as a plain square, and a
// card in a continuous block leans on the shared group frame for both.
const tileShape = !group
? cn(shape.container, "overflow-hidden")
: separated && outlined
? cn(shape.container, "overflow-hidden border border-border/60")
: "";
// Stretched overlay makes the whole card the click target while keeping
// action buttons (higher z) independently clickable — the accessible
// alternative to nesting interactive elements inside a button/anchor. A
// disabled card drops the overlay entirely so it can't be tabbed to or
// activated by keyboard (pointer-events-none only blocks the mouse).
const overlay = clickable && !disabled ? (
href ? (
<Link
href={href}
onClick={onClick}
target={external ? "_blank" : undefined}
rel={external ? "noopener noreferrer" : undefined}
aria-label={label}
className="absolute inset-0 z-20 outline-none focus-visible:ring-1 focus-visible:ring-[color:var(--focus-ring,#6B97FF)] rounded-[inherit]"
/>
) : (
<button
type="button"
onClick={onClick}
aria-label={label}
aria-pressed={selected || undefined}
className="absolute inset-0 z-20 outline-none focus-visible:ring-1 focus-visible:ring-[color:var(--focus-ring,#6B97FF)] rounded-[inherit]"
/>
)
) : null;
const cardContext = useMemo<CardContextValue>(
() => ({ emphasized, orientation, clickable, hasImage }),
[emphasized, orientation, clickable, hasImage]
);
// Inline image cards wrap their non-image parts in a centred column so the
// title, description, and actions hug together and sit vertically centred
// against the image instead of stretching to its full height.
let body: ReactNode = children;
if (inlineImage) {
const parts = Children.toArray(children);
const image = parts.find(isCardImage);
const rest = parts.filter((part) => part !== image);
body = (
<>
{image}
<div
className={cn(
"flex min-w-0 flex-1 flex-col justify-center gap-2",
compact ? "py-2.5 pr-3" : "py-3.5 pr-4"
)}
>
{rest}
</div>
</>
);
}
const card = (
<CardContext.Provider value={cardContext}>
<div
ref={(node) => {
(internalRef as React.MutableRefObject<HTMLDivElement | null>).current =
node;
if (typeof ref === "function") ref(node);
else if (ref)
(ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
}}
data-slot="card"
data-fluid-hover-index={index}
data-selected={selected || undefined}
data-orientation={orientation}
aria-disabled={disabled || undefined}
className={cn(
"group/card relative z-10 min-w-0 min-h-[60px]",
inlineImage
? // Image on the left; the text + actions ride in a centred
// column beside it (see the wrapper in the body below).
cn("flex flex-row items-center", compact ? "gap-2.5" : "gap-3")
: isInline
? cn(
"flex flex-row items-center",
compact ? "gap-2.5 pl-3" : "gap-3 pl-4"
)
: cn("flex flex-col", compact ? "pb-3" : "pb-4"),
// Standalone (no group) cards can't lean on the group highlight, so
// they carry their own hover tint when interactive.
!group && clickable && !disabled && "transition-colors duration-80 hover:bg-hover",
// Inline rows are single-line, so the corner dismiss would sit on
// the title's tail: the header yields right padding whenever the
// control is present — only while it's revealed in the on-hover
// case, so the resting text keeps the full row (the group-header
// gutter-swap pattern; instant, not transitioned).
dismissible &&
isInline &&
(dismissOnHover
? "[&:hover_[data-slot=card-header]]:pr-10 [&:focus-within_[data-slot=card-header]]:pr-10"
: "[&_[data-slot=card-header]]:pr-10"),
tileShape,
disabled && "opacity-50 pointer-events-none",
className
)}
{...props}
>
{/* Persistent selected fill + dividers sit behind the static content
(-z-10); the stretched overlay (z-20) sits above content so the
whole card is clickable, and actions/dismiss (z-30) rise above the
overlay to stay independently interactive. */}
{selected && (
<span
aria-hidden
className={cn("absolute inset-0 -z-10 bg-active pointer-events-none", shape.container)}
/>
)}
{/* Dividers between borderless neighbours. Where both hairlines meet,
the vertical one stops 1px short so the horizontal hairline owns
the crossing pixel — two 60% lines would otherwise stack there and
read brighter than the rest of the grid. */}
{showBottom && (
<span
aria-hidden
className="absolute inset-x-0 bottom-0 h-px bg-border/60 pointer-events-none -z-10"
/>
)}
{showRight && (
<span
aria-hidden
className={cn(
"absolute top-0 right-0 w-px bg-border/60 pointer-events-none -z-10",
showBottom ? "bottom-px" : "bottom-0"
)}
/>
)}
{overlay}
{body}
{/* Dismiss control sits above the stretched overlay. */}
{dismissible && (
<button
type="button"
onClick={onDismiss}
aria-label="Dismiss"
className={cn(
"absolute right-2 top-2 z-30 flex h-7 w-7 items-center justify-center text-muted-foreground hover:text-foreground cursor-pointer outline-none transition-colors duration-80 focus-visible:ring-1 focus-visible:ring-[color:var(--focus-ring,#6B97FF)]",
// Over media the control needs its own ground, or the icon
// reads against whatever the image happens to be. Elsewhere
// the usual hover fill is enough. The chip takes the button
// radius either way.
hasImage
? "bg-card/70 backdrop-blur-sm hover:bg-card"
: "hover:bg-hover",
// Revealed on hover / focus. focus-visible covers the keyboard
// path, since the control is the only way to dismiss.
// pointer-events gates alongside opacity: an invisible
// control must not swallow taps meant for the card (touch has
// no hover to reveal it). Keyboard focus still reaches it —
// pointer-events never blocks tabbing.
dismissOnHover &&
"pointer-events-none opacity-0 transition-opacity duration-80 group-hover/card:pointer-events-auto group-hover/card:opacity-100 group-focus-within/card:pointer-events-auto group-focus-within/card:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100",
shape.button
)}
>
<XIcon size={compact ? 13 : 15} strokeWidth={1.5} />
</button>
)}
</div>
</CardContext.Provider>
);
// A size prop pins the card (and everything ladder-aware inside it) to one
// ladder step; the parts read the context.
return size ? <SizeProvider size={size}>{card}</SizeProvider> : card;
}
);
Card.displayName = "Card";
// ── CardHeader ───────────────────────────────────────────
// shadcn's header grid: title + description stack, with CardAction pinned to
// the top-right column. In an inline card it becomes the flexible text column
// between the leading media and trailing footer.
const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { orientation, hasImage } = useContext(CardContext);
const inlineImage = orientation === "inline" && hasImage;
const sizeClasses = useSize();
const compact = sizeClasses.variant === "compact";
return (
<div
ref={ref}
data-slot="card-header"
className={cn(
"grid auto-rows-min items-start gap-1 has-data-[slot=card-action]:grid-cols-[1fr_auto]",
inlineImage
? "min-w-0"
: orientation === "inline"
? cn("min-w-0 flex-1", compact ? "py-2.5" : "py-3.5")
: compact
? "px-3 pt-3"
: "px-4 pt-4",
className
)}
{...props}
/>
);
}
);
CardHeader.displayName = "CardHeader";
// ── CardTitle ────────────────────────────────────────────
const CardTitle = forwardRef<HTMLSpanElement, HTMLAttributes<HTMLSpanElement>>(
({ className, children, ...props }, ref) => {
const { emphasized, orientation } = useContext(CardContext);
const sizeClasses = useSize();
const compact = sizeClasses.variant === "compact";
// Inline rows trim the title to cap height so it centres tightly against
// the media/actions; stacked cards keep the natural line box.
const trim =
orientation === "inline" ? "[text-box:trim-both_cap_alphabetic]" : "";
// Ghost-span pattern: an invisible semibold copy reserves the width so the
// resting→active weight animation never reflows the row.
//
// Truncation support: a consumer's `truncate` lands on this grid, whose
// text-overflow can't ellipsize (only a block's own line boxes can — grid
// children just clip). The nowrap it sets inherits into the cell spans,
// so giving those spans overflow-hidden + ellipsis renders the … there
// instead; minmax(0,1fr) clamps the implicit track to the clipped grid's
// width so the ellipsis falls inside the visible box. All of it is inert
// while titles wrap normally.
return (
<span
ref={ref}
data-slot="card-title"
className={cn(
"inline-grid grid-cols-[minmax(0,1fr)]",
typeClass("subtitle", compact ? "compact" : "default"),
className
)}
{...props}
>
<span
className={cn("col-start-1 row-start-1 invisible min-w-0 overflow-hidden text-ellipsis", trim)}
style={{ fontVariationSettings: fontWeights.semibold }}
aria-hidden="true"
>
{children}
</span>
<span
className={cn(
"col-start-1 row-start-1 min-w-0 overflow-hidden text-ellipsis text-foreground transition-[font-variation-settings] duration-80",
trim
)}
style={{
// normal → semibold on emphasis, matching nav-item / menu-item /
// table (the opsz-paired tokens keep the advance width ~constant).
fontVariationSettings: emphasized
? fontWeights.semibold
: fontWeights.normal,
}}
>
{children}
</span>
</span>
);
}
);
CardTitle.displayName = "CardTitle";
// ── CardDescription ──────────────────────────────────────
const CardDescription = forwardRef<
HTMLParagraphElement,
HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const sizeClasses = useSize();
const compact = sizeClasses.variant === "compact";
return (
<p
ref={ref}
data-slot="card-description"
className={cn(
"text-muted-foreground",
typeClass("subtitle", compact ? "compact" : "default"),
className
)}
{...props}
/>
);
});
CardDescription.displayName = "CardDescription";
// ── CardAction ───────────────────────────────────────────
// Pinned to the header's top-right column (shadcn's slot). Sits above the
// stretched overlay so any control inside stays independently clickable.
const CardAction = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="card-action"
className={cn(
"relative z-30 col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
);
CardAction.displayName = "CardAction";
// ── CardContent ──────────────────────────────────────────
const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { orientation } = useContext(CardContext);
const sizeClasses = useSize();
const compact = sizeClasses.variant === "compact";
return (
<div
ref={ref}
data-slot="card-content"
className={cn(
orientation === "inline" ? "" : compact ? "px-3 pt-2.5" : "px-4 pt-3",
className
)}
{...props}
/>
);
}
);
CardContent.displayName = "CardContent";
// ── CardFooter ───────────────────────────────────────────
// Actions row. Rises above the stretched overlay (z-30) so buttons stay
// clickable. In an inline card it becomes the trailing, right-aligned slot.
const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { orientation, hasImage } = useContext(CardContext);
const inlineImage = orientation === "inline" && hasImage;
const sizeClasses = useSize();
const compact = sizeClasses.variant === "compact";
return (
<div
ref={ref}
data-slot="card-footer"
className={cn(
"relative z-30 flex items-center gap-1",
inlineImage
? // Under the text, left-aligned in natural order (the inline
// wrapper owns the spacing).
"flex-wrap"
: orientation === "inline"
? cn("shrink-0 ml-auto", compact ? "pr-3" : "pr-4")
: cn("flex-wrap", compact ? "px-3 pt-2.5" : "px-4 pt-3"),
className
)}
{...props}
/>
);
}
);
CardFooter.displayName = "CardFooter";
// ── CardMedia ────────────────────────────────────────────
// FF addition: a leading icon or brand logo(s). Not part of shadcn's anatomy,
// but the connective tissue most product cards need. A tuple renders a
// connected logo pair (e.g. a trigger — target).
type CardLogo = string | [string, string];
interface CardMediaProps {
logo?: CardLogo;
logoAlt?: string;
icon?: IconComponent;
size?: number;
className?: string;
}
function CardMedia({ logo, logoAlt, icon: Icon, size = 22, className }: CardMediaProps) {
const { orientation } = useContext(CardContext);
const shape = useShape();
const sizeClasses = useSize();
const compact = sizeClasses.variant === "compact";
// Stacked: sits in the header grid; add an extra 8px so the gap below the
// icon reads 12px (header gap-1 + mb-2). Inline: leading slot — the card owns
// the left inset, so no extra padding here.
const wrap = cn(orientation === "inline" ? "" : "mb-2", className);
if (logo) {
const logos = Array.isArray(logo) ? logo : [logo];
return (
<span
data-slot="card-media"
className={cn("inline-flex items-center gap-1.5 shrink-0", wrap)}
>
{logos.map((src, i) => (
<span key={i} className="inline-flex items-center gap-1.5">
{i > 0 && <span aria-hidden className="w-2 h-px bg-border" />}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={logoAlt ?? ""}
width={size}
height={size}
className={cn(
// Inset image edge: no layout change, theme-neutral ink.
"object-contain outline-1 -outline-offset-1 outline-black/10 dark:outline-white/10",
shape.bg
)}
style={{ width: size, height: size }}
/>
</span>
))}
</span>
);
}
if (Icon) {
// The icon sits in a 32×32 tinted tile so it reads as a media slot rather
// than a bare glyph. The tile is an overlay tint (not a solid surface) so it
// blends over whatever is behind it — the substrate or the hover highlight.
return (
<span
data-slot="card-media"
className={cn(
"inline-flex items-center justify-center shrink-0 size-8 bg-hover",
shape.bg,
wrap
)}
>
<Icon size={compact ? 16 : 18} strokeWidth={1.5} className="text-muted-foreground" />
</span>
);
}
return null;
}
// ── CardImage ────────────────────────────────────────────
// FF addition: the prominent, full-bleed image (distinct from a small logo).
// Stacked → a top banner; inline → a full-height leading image on the left that
// bleeds past the card's left inset to sit flush against the edge.
interface CardImageProps {
src: string;
alt?: string;
className?: string;
}
function CardImage({ src, alt, className }: CardImageProps) {
const { orientation } = useContext(CardContext);
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt={alt ?? ""}
data-slot="card-image"
// The image keeps a fixed 2px corner radius in every state — stacked or
// inline, framed or borderless — rather than inheriting a frame's larger
// clip. The inset outline paints over the edge pixels, so it neither
// changes this geometry nor disappears when a pale image meets the page.
// (A framed tile still clips the surrounding surface as before.)
className={cn(
"object-cover rounded-[2px] outline-1 -outline-offset-1 outline-black/10 dark:outline-white/10",
orientation === "inline"
? "size-40 shrink-0"
: "w-full aspect-[16/9]",
className
)}
/>
);
}
// A stable marker so the Card can recognise an image child by name, surviving
// module-identity mismatches (e.g. HMR duplication) that break `type ===`.
CardImage.displayName = "CardImage";
// ── CardEyebrow ──────────────────────────────────────────
// Small label above the title (e.g. "New Model"). Deprecated: Fluid
// Functionalism doesn't use eyebrows, a card starts with its title. Kept so
// existing code keeps compiling.
/** @deprecated No eyebrows: a card starts with its title. */
const CardEyebrow = forwardRef<HTMLSpanElement, HTMLAttributes<HTMLSpanElement>>(
({ className, ...props }, ref) => {
const compact = useSize().variant === "compact";
return (
<span
ref={ref}
data-slot="card-eyebrow"
className={cn(
typeClass("caption", compact ? "compact" : "default"),
"text-muted-foreground",
className
)}
style={{ fontVariationSettings: fontWeights.semibold }}
{...props}
/>
);
}
);
CardEyebrow.displayName = "CardEyebrow";
// ── CardFeature ──────────────────────────────────────────
// FF addition: an icon + title + description row, for feature lists inside
// CardContent.
interface CardFeatureProps {
icon?: IconComponent;
title: string;
description?: string;
}
function CardFeature({ icon: Icon, title, description }: CardFeatureProps) {
const sizeClasses = useSize();
const compact = sizeClasses.variant === "compact";
return (
<div
data-slot="card-feature"
className={cn("flex items-start", sizeClasses.gap)}
>
{Icon && (
<Icon
size={sizeClasses.icon}
strokeWidth={1.5}
className="mt-0.5 shrink-0 text-muted-foreground"
/>
)}
<div className="flex flex-col gap-0.5 min-w-0">
<span
className={cn(
"text-foreground [text-box:trim-both_cap_alphabetic]",
sizeClasses.text
)}
style={{ fontVariationSettings: fontWeights.semibold }}
>
{title}
</span>
{description && (
<span
className={cn(
"text-muted-foreground",
typeClass("caption", compact ? "compact" : "default")
)}
>
{description}
</span>
)}
</div>
</div>
);
}
// ── CardButton ───────────────────────────────────────────
// Self-contained action button for the footer (keeps Card free of a Button
// dependency, so it installs standalone). Renders an anchor when `href` is set.
type CardButtonVariant = "primary" | "secondary" | "ghost" | "link";
const CARD_BUTTON_VARIANTS: Record<CardButtonVariant, string> = {
primary: "bg-foreground text-background hover:bg-foreground/90 active:bg-foreground/80",
secondary: "bg-accent text-foreground hover:bg-accent/80 active:bg-accent",
ghost: "text-muted-foreground hover:text-foreground hover:bg-hover active:bg-active",
link: "text-foreground underline-offset-4 hover:underline !px-0 !h-auto",
};
interface CardButtonProps {
children: React.ReactNode;
onClick?: () => void;
href?: string;
variant?: CardButtonVariant;
icon?: IconComponent;
iconPosition?: "start" | "end";
/** Opens the href in a new tab and appends an outward arrow glyph. */
external?: boolean;
disabled?: boolean;
}
function CardButton({
children,
onClick,
href,
variant = "ghost",
icon: Icon,
iconPosition,
external = false,
disabled = false,
}: CardButtonProps) {
const shape = useShape();
const icons = useIcons();
const ArrowRight = icons["arrow-right"];
const sizeClasses = useSize();
const compact = sizeClasses.variant === "compact";
const position = iconPosition ?? (external ? "end" : "start");
const glyph = Icon ? (
<Icon
size={compact ? 12 : 14}
strokeWidth={1.5}
className="shrink-0 transition-[stroke-width] duration-80 group-hover/action:stroke-[2]"
/>
) : null;
const externalGlyph = external ? (
<ArrowRight
size={13}
strokeWidth={1.5}
className="shrink-0 -rotate-45 transition-[stroke-width] duration-80 group-hover/action:stroke-[2]"
/>
) : null;
const inner = (
<>
{position === "start" && glyph}
<span className="[text-box:trim-both_cap_alphabetic]">{children}</span>
{position === "end" && glyph}
{externalGlyph}
</>
);
const classes = cn(
"group/action relative z-30 inline-flex items-center justify-center gap-1.5 h-7 px-2.5 cursor-pointer outline-none",
typeClass("caption", compact ? "compact" : "default"),
"transition-colors duration-80",
"focus-visible:ring-1 focus-visible:ring-[color:var(--focus-ring,#6B97FF)]",
"disabled:opacity-50 disabled:pointer-events-none",
shape.button,
CARD_BUTTON_VARIANTS[variant]
);
if (href) {
return (
<Link
href={href}
onClick={onClick}
target={external ? "_blank" : undefined}
rel={external ? "noopener noreferrer" : undefined}
className={classes}
style={{ fontVariationSettings: fontWeights.normal }}
>
{inner}
</Link>
);
}
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={classes}
style={{ fontVariationSettings: fontWeights.normal }}
>
{inner}
</button>
);
}
export {
Card,
CardGroup,
CardHeader,
CardTitle,
CardDescription,
CardAction,
CardContent,
CardFooter,
CardMedia,
CardImage,
CardEyebrow,
CardFeature,
CardButton,
};
export type {
CardProps,
CardGroupProps,
CardLogo,
CardButtonProps,
CardButtonVariant,
};