shadimage
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 · medium
animationframer-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,
};

More from fluid

Browse all components in this library →