docs / core / card

Card

the base surface, composed not configured. header, body and footer are regions you fill, so a card is whatever you assemble in them. one surface word sets its weight, from elevated to a bare outline, and StatCard, Panel and Dialog all speak it.

intensity · recedesrsc · server

provenance

shaped from personal-website · sisu-plus

layer

core / primitives

intensity

recedes · safe anywhere, including dense surfaces

composition

holds Button, Badge, StatCard content, forms, anything groupedGlowCard swaps in for the single card that earns the pointer glownever a card inside a card. nest with Panel or plain spacinginteractive without an actual click target is a lie

a11y

CardTitle is a real <h3> · the glow layer is aria-hiddenjest-axe

dependencies

Badge from the same package
live demo · try it out

Deployment

Production build

live

Shipped 4 minutes ago.

customize
surfacehow the card sits on the page
highlightaccent wash, edge, or full ring
interactivelifts on hover for clickable cards
row (CardHeader)icon + title + actions inline
usagecopy
import { Card, CardActions, CardBadge, CardBody, CardEyebrow, CardFooter, CardHeader, CardIcon, CardTitle } from "usva/primitives/card";

<Card interactive>
  <CardHeader row>
    <CardIcon><BoltIcon /></CardIcon>
    <div className="flex flex-col gap-1">
      <CardEyebrow>Deployment</CardEyebrow>
      <CardTitle>Production build</CardTitle>
    </div>
    <CardActions>
      <CardBadge tone="success">live</CardBadge>
    </CardActions>
  </CardHeader>
  <CardBody>Shipped 4 minutes ago.</CardBody>
  <CardFooter>
    <Button size="sm" variant="outline">View logs</Button>
  </CardFooter>
</Card>
highlights

highlight = "wash"

pick a highlight

a radial accent wash bleeds atmosphere across the surface.

surfaceshared with StatCard · Panel · Dialog

surface = "elevated"

pick a surface

the default. lit-from-above fill, a rim light and a floating shadow.

props

proptypedefaultnotes
surface"elevated" | "flat" | "glass" | "outline""elevated"how the card sits on the page. the same vocabulary drives StatCard, Panel and Dialog. glass is a rare, purposeful choice, never a default.
highlight"none" | "wash" | "edge" | "ring""none"accent treatment: a radial wash, a top edge hairline, or a full glow ring. ring replaces the elevation shadow.
interactivebooleanfalselifts on hover. only for cards that are actually clickable.
row (CardHeader)booleanfalselays the header out as icon + title + actions instead of stacked.

get it

npx shadcn add https://usva.build/r/card.jsoncopy
source · components/ui/card.tsxexactly what this command copiescopy
import * as React from "react";
import { cn } from "@/lib/utils";
import { Badge, type BadgeProps } from "./badge";

type Div = React.HTMLAttributes<HTMLDivElement>;
const make = (base: string, name: string) =>
  Object.assign(
    React.forwardRef<HTMLDivElement, Div>(({ className, ...p }, ref) => (
      <div ref={ref} className={cn(base, className)} {...p} />
    )),
    { displayName: name },
  );

export type CardHighlight = "none" | "wash" | "edge" | "ring";

/**
 * The shared surface vocabulary. One word picks how any card-like surface
 * (Card, StatCard, Panel, Dialog) sits on the page, and the same choice reads
 * consistently across all of them.
 *   elevated: the default. Lit-from-above surface fill, rim light, shadow.
 *   flat:     quiet surface fill, no lift. The sisu/dashboard workhorse.
 *   glass:    translucent, blurs what sits behind it. A rare, purposeful
 *             choice, never a default.
 *   outline:  transparent, carried by its border alone.
 */
export type CardSurface = "elevated" | "flat" | "glass" | "outline";

/** Background + rim treatment per surface. No shadow; consumers pick the level. */
export const SURFACE_SKIN: Record<CardSurface, string> = {
  elevated: "rim-light bg-surface",
  flat: "bg-surface",
  glass: "rim-light bg-surface/65 backdrop-blur-md",
  outline: "bg-transparent",
};

/** Whether a surface reads as raised (gets its host's elevation shadow). */
export const SURFACE_ELEVATED: Record<CardSurface, boolean> = {
  elevated: true,
  flat: false,
  glass: true,
  outline: false,
};

export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
  interactive?: boolean;
  highlight?: CardHighlight;
  surface?: CardSurface;
  /** @deprecated use `highlight="wash"` */
  wash?: boolean;
}

export const Card = React.forwardRef<HTMLDivElement, CardProps>(
  (
    {
      className,
      interactive,
      highlight = "none",
      surface = "elevated",
      wash,
      ...p
    },
    ref,
  ) => {
    const resolved: CardHighlight =
      highlight === "none" && wash ? "wash" : highlight;
    return (
      <div
        ref={ref}
        data-interactive={interactive ? "" : undefined}
        data-highlight={resolved !== "none" ? resolved : undefined}
        data-surface={surface}
        className={cn(
          "relative isolate rounded-2xl border border-border text-ink",
          SURFACE_SKIN[surface],
          resolved === "ring"
            ? "glow-ring"
            : SURFACE_ELEVATED[surface] && "shadow-floating",
          resolved === "wash" && "wash-accent",
          resolved === "edge" &&
            "after:absolute after:inset-x-5 after:top-0 after:h-px after:hairline-accent",
          interactive &&
            "transition-control duration-base ease-soft hover:-translate-y-0.5 motion-reduce:transform-none motion-reduce:transition-none",
          interactive && resolved !== "ring" && "hover:shadow-overlay",
          className,
        )}
        {...p}
      />
    );
  },
);
Card.displayName = "Card";

export interface CardHeaderProps extends Div {
  row?: boolean;
}

export const CardHeader = React.forwardRef<HTMLDivElement, CardHeaderProps>(
  ({ className, row, ...p }, ref) => (
    <div
      ref={ref}
      className={cn(
        "relative p-5 after:absolute after:inset-x-5 after:bottom-0 after:h-px after:hairline-accent",
        row ? "flex flex-row items-start gap-4" : "flex flex-col gap-1.5",
        className,
      )}
      {...p}
    />
  ),
);
CardHeader.displayName = "CardHeader";

export const CardBody = make("p-5", "CardBody");
export const CardFooter = make(
  "flex items-center gap-2 p-5 border-t border-border",
  "CardFooter",
);

export const CardEyebrow = React.forwardRef<
  HTMLParagraphElement,
  React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...p }, ref) => (
  <p
    ref={ref}
    className={cn(
      "font-mono text-[0.65rem] uppercase leading-none tracking-[0.16em] text-muted",
      className,
    )}
    {...p}
  />
));
CardEyebrow.displayName = "CardEyebrow";

export interface CardTitleProps
  extends React.HTMLAttributes<HTMLHeadingElement> {
  /** Heading level. Pick it to fit the page outline. */
  as?: "h2" | "h3" | "h4" | "h5" | "h6";
}

export const CardTitle = React.forwardRef<HTMLHeadingElement, CardTitleProps>(
  ({ className, as: Heading = "h3", ...p }, ref) => (
    <Heading
      ref={ref}
      className={cn(
        "text-balance text-sm font-semibold tracking-[-0.01em] text-ink",
        className,
      )}
      {...p}
    />
  ),
);
CardTitle.displayName = "CardTitle";

export const CardIcon = make(
  "inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border bg-surface-2 text-muted [&_svg]:h-4 [&_svg]:w-4",
  "CardIcon",
);

export const CardActions = make(
  "ml-auto flex items-center gap-1",
  "CardActions",
);

export function CardBadge({ className, ...props }: BadgeProps) {
  return <Badge className={cn("self-start", className)} {...props} />;
}
CardBadge.displayName = "CardBadge";
source · components/ui/glow-card.tsxexactly what this command copiescopy
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { Card, type CardProps } from "./card";

/**
 * A Card whose border lights up on the edge facing the pointer. The arc turns
 * to follow the cursor and brightens as you approach the edge, so a single card
 * gets the same directional glow the BentoGrid shares across a whole grid.
 */
export const GlowCard = React.forwardRef<HTMLDivElement, CardProps>(
  ({ className, children, ...props }, ref) => {
    const localRef = React.useRef<HTMLDivElement | null>(null);
    const setRefs = React.useCallback(
      (node: HTMLDivElement | null) => {
        localRef.current = node;
        if (typeof ref === "function") ref(node);
        else if (ref) ref.current = node;
      },
      [ref],
    );

    const onPointerMove = React.useCallback(
      (e: React.PointerEvent<HTMLDivElement>) => {
        const el = localRef.current;
        if (!el) return;
        const r = el.getBoundingClientRect();
        el.style.setProperty("--edge-x", `${e.clientX - r.left}px`);
        el.style.setProperty("--edge-y", `${e.clientY - r.top}px`);
        el.style.setProperty("--edge-o", "1");
      },
      [],
    );

    const onPointerLeave = React.useCallback(() => {
      localRef.current?.style.setProperty("--edge-o", "0");
    }, []);

    return (
      <Card
        ref={setRefs}
        className={cn("relative", className)}
        onPointerMove={onPointerMove}
        onPointerLeave={onPointerLeave}
        {...props}
      >
        <span aria-hidden className="edge-glow" />
        {children}
      </Card>
    );
  },
);
GlowCard.displayName = "GlowCard";