← ui

item-card

ui

an item's parts on a card: its kind, its author, its counts, opening it whole

npx shadcn add @ag/item-card
Already installed? Preview an update
npx shadcn add @ag/item-card --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-11-04
  • New: an item's parts on a card, swift-utils' inkCard parts on the web: CardKind (the kind in the card's hue, its number at 65%), CardAuthor (a round face, initials without one, and the name, taking a footer's leading side) and CardCount (a count a person acts on, vote's pill, the icon filled in the hue when it is theirs). useCardView and CardView open a card whole (Ink's inkFullView): it grows out of its place into a centred card over the page dimmed and blurred, and esc, a press outside or its ✕ carry it back. The card itself, its hue by the flare rule and its light under the hand, is card's.
Updated 2026-11-04 · release notes

also called ticket

shelf glass & surfaces

uses card, material, avatar, button, dialog

used by drag-list, kanban

the demo above, verbatim

item-card.demo.tsx
"use client"

import { ArrowUp, MessageCircle } from "lucide-react"
import * as React from "react"
import { Sample } from "@/app/samples"
import { Backdrop } from "@/registry/base-nova/ui/backdrop"
import { Card, CardFooter, CardTitle } from "@/registry/base-nova/ui/card"
import {
  CardAuthor,
  CardCount,
  CardKind,
  CardView,
  useCardView,
} from "@/registry/base-nova/ui/item-card"
import {
  type Material,
  MaterialProvider,
} from "@/registry/base-nova/ui/material"

// #region cards
// Three cards, each wearing its kind's hue by the flare rule: glass stained by it, its
// rim catching it; plain the page's card, the hue only in its marks. The same cards on
// the page and over a photograph. Under the pointer a card catches more light, and
// nothing moves.
type Ticket = {
  id: string
  kind: "feat" | "fix" | "chore"
  number: string
  title: string
  body: string
  author: string
  face?: string
  votes: number
  comments: number
}

const HUE: Record<Ticket["kind"], string> = {
  feat: "oklch(68% 0.13 250)",
  fix: "oklch(64% 0.19 27)",
  chore: "oklch(72% 0.14 155)",
}

const TICKETS: Ticket[] = [
  {
    id: "t21",
    kind: "feat",
    number: "#21",
    title: "offline downloads",
    body: "Keep a season on the phone for a flight: picked per episode, deleted once watched.",
    author: "Adrian Galilea",
    face: "/adriangalilea.jpg",
    votes: 12,
    comments: 4,
  },
  {
    id: "t34",
    kind: "fix",
    number: "#34",
    title: "subtitles drift after a seek",
    body: "Seeking back thirty seconds leaves the captions a beat late until the next cue.",
    author: "Walter Cronkite",
    face: "/people/cronkite.jpg",
    votes: 7,
    comments: 2,
  },
  {
    id: "t40",
    kind: "chore",
    number: "#40",
    title: "move the build to the new runner",
    body: "The old one is retired at the end of the month.",
    author: "Ada Lovelace",
    votes: 3,
    comments: 0,
  },
]

/** A ticket's parts, as every card writes them: its kind, its title, its foot. */
function TicketFace({
  ticket,
  voted,
  onVote,
}: {
  ticket: Ticket
  voted: boolean
  onVote: () => void
}) {
  return (
    <>
      <CardKind number={ticket.number}>{ticket.kind}</CardKind>
      <CardTitle className="font-semibold">{ticket.title}</CardTitle>
      <CardFooter>
        <CardAuthor name={ticket.author} src={ticket.face} />
        <span className="flex items-center gap-1">
          <CardCount
            icon={MessageCircle}
            count={ticket.comments}
            said="comments"
          />
          <CardCount
            icon={ArrowUp}
            count={ticket.votes + (voted ? 1 : 0)}
            on={voted}
            said="votes"
            onClick={onVote}
          />
        </span>
      </CardFooter>
    </>
  )
}

function Tickets({ material }: { material: Material }) {
  const [voted, setVoted] = React.useState<ReadonlySet<string>>(new Set())
  const vote = (id: string) =>
    setVoted((v) => {
      const next = new Set(v)
      if (next.has(id)) next.delete(id)
      else next.add(id)
      return next
    })
  return (
    <div className="grid gap-4 sm:grid-cols-3">
      {TICKETS.map((t) => (
        <Card
          key={t.id}
          hue={HUE[t.kind]}
          material={material}
          padding="regular"
          interactive
        >
          <TicketFace
            ticket={t}
            voted={voted.has(t.id)}
            onVote={() => vote(t.id)}
          />
        </Card>
      ))}
    </div>
  )
}
// #endregion

// #region opened
// A card opened whole: a click (or Enter) grows it out of its place into a card of its
// own over the page; esc, a press outside or its ✕ put it back where it came from.
function Opened() {
  const view = useCardView<string>()
  const opened = TICKETS.find((t) => t.id === view.opened)
  return (
    <div className="grid gap-4 sm:grid-cols-3">
      {TICKETS.map((t) => (
        <Card
          key={t.id}
          hue={HUE[t.kind]}
          padding="regular"
          interactive
          role="button"
          tabIndex={0}
          aria-label={`open ${t.title}`}
          onClick={() => view.show(t.id)}
          onKeyDown={(e) => {
            if (e.key === "Enter") view.show(t.id)
          }}
          style={view.source(t.id)}
          className="cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-ring"
        >
          <CardKind number={t.number}>{t.kind}</CardKind>
          <CardTitle className="font-semibold">{t.title}</CardTitle>
          <CardFooter>
            <CardAuthor name={t.author} src={t.face} />
          </CardFooter>
        </Card>
      ))}
      {opened && (
        <CardView view={view} title={opened.title} hue={HUE[opened.kind]}>
          <CardKind number={opened.number}>{opened.kind}</CardKind>
          <p className="text-pretty text-sm text-current/70">{opened.body}</p>
          <CardFooter>
            <CardAuthor name={opened.author} src={opened.face} />
          </CardFooter>
        </CardView>
      )}
    </div>
  )
}
// #endregion

export default function ItemCardDemo() {
  return (
    <div className="space-y-8">
      <Sample
        name="cards"
        glance="wide"
        title="tickets, on the page"
        description="Each a card in its kind's hue by the flare rule, with an item's parts: plain, the hue in its marks alone (its kind, a vote that is yours). Point at one: it catches more light, nothing moves. Vote: the arrow fills in the card's hue."
        with="cards"
      >
        <Tickets material="plain" />
      </Sample>
      <Sample
        name="glass"
        title="tickets, in glass"
        description="The same cards over two photographs, each with light and dark in it: glass stained by the hue, its rim catching it, the foot the glass's own front, as thick as the words need over what is under it."
        with="cards"
      >
        <MaterialProvider material="glass">
          <div className="space-y-4">
            {["/lightbox/card.jpg", "/lightbox/tall.jpg"].map((src) => (
              <Backdrop
                key={src}
                src={src}
                className="w-full rounded-3xl p-6 sm:p-8"
              >
                <Tickets material="glass" />
              </Backdrop>
            ))}
          </div>
        </MaterialProvider>
      </Sample>
      <Sample
        name="opened"
        title="a ticket opened whole"
        description="Click a card, or Tab to it and press Enter: it grows out of its place into a card of its own over the page. Esc, a click outside or its ✕ put it back where it came from."
        with="opened"
      >
        <Opened />
      </Sample>
    </div>
  )
}

the source, every file an install writes

item-card.tsx
"use client"

// AN ITEM CARD: one thing a person reads and acts on (a ticket, a step, a post), on
// `card` (swift-utils Ink's `inkCard` and its parts on the web). The box is a card's
// own, its hue by the flare rule, its padding and the hand's light being card's
// options:
//
//   <Card hue="oklch(68% 0.13 250)" padding="compact" interactive>
//     <CardKind number="#21">feat</CardKind>
//     <CardTitle>offline downloads</CardTitle>
//     <CardFooter>
//       <CardAuthor name="Adrian Galilea" />
//       <CardCount icon={ArrowUp} count={12} on={voted} said="votes" onClick={vote} />
//     </CardFooter>
//   </Card>
//
// What only an item has lives here: its kind, who wrote it, its counts, and opening it
// whole (Ink's `inkFullView`: `useCardView` and `CardView`, a card growing out of its
// place into a centred card over the page dimmed and blurred, and back). An item of
// its own because those parts need an avatar, a dialog and a button, which a card
// holding a form or a list should never install; the parts read the card they stand
// in (`useCardFace`) and its hue (`--card-ink`), told nothing twice.

import { X } from "lucide-react"
import * as React from "react"
import { flushSync } from "react-dom"
import { cn } from "@/lib/utils"
import { Avatar } from "@/registry/base-nova/ui/avatar"
import { Button } from "@/registry/base-nova/ui/button"
import { Card, useCardFace } from "@/registry/base-nova/ui/card"
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogTitle,
} from "@/registry/base-nova/ui/dialog"
import type { Material } from "@/registry/base-nova/ui/material"

/** An item's kind, the word in its hue (`feat`, `fix`), and its number after it,
 *  at the muted 65%. Monospaced and small: it files the card, the title says it. */
export function CardKind({
  number,
  className,
  children,
}: {
  number?: React.ReactNode
  className?: string
  children: React.ReactNode
}) {
  useCardFace("CardKind")
  return (
    <p
      data-slot="card-kind"
      className={cn("flex gap-2 font-mono text-[10px] leading-none", className)}
    >
      <span className="text-[var(--card-ink,color-mix(in_oklab,currentColor_65%,transparent))]">
        {children}
      </span>
      {number !== undefined && (
        <span className="text-current/65">{number}</span>
      )}
    </p>
  )
}

/** Who wrote an item: their round face (their initial when there is none, avatar's
 *  own) and their name, small and at the muted 65%. In a footer it takes the leading
 *  side. */
export function CardAuthor({
  name,
  src,
  className,
}: {
  name: string
  /** Their picture; absent, their initial. */
  src?: string
  className?: string
}) {
  useCardFace("CardAuthor")
  return (
    <span
      data-slot="card-author"
      className={cn("flex min-w-0 items-center gap-2", className)}
    >
      {/* The name stands beside it: the face is decorative. */}
      <Avatar src={src} alt="" name={name} size="sm" />
      <span className="truncate text-current/65">{name}</span>
    </span>
  )
}

/** A count an item carries and a person acts on (its comments, its votes): vote's
 *  pill, the icon and the number. `on` is a count that is theirs (their vote): the
 *  icon filled in the card's hue. `said` names what is counted, for a screen reader. */
export function CardCount({
  icon: Icon,
  count,
  on = false,
  said,
  onClick,
  className,
}: {
  icon: React.ComponentType<{ className?: string; "aria-hidden"?: boolean }>
  count: number
  on?: boolean
  said: string
  onClick?: () => void
  className?: string
}) {
  const { material } = useCardFace("CardCount")
  return (
    <Button
      variant="ghost"
      size="sm"
      corners="pill"
      material={material}
      pressed={on}
      aria-label={`${count} ${said}`}
      data-carry-ignore=""
      onClick={(e) => {
        // A count on a card that opens is its own press.
        e.stopPropagation()
        onClick?.()
      }}
      className={cn("gap-1 tabular-nums", className)}
    >
      <Icon
        aria-hidden
        className={cn(
          "size-3.5",
          on
            ? "fill-current text-[var(--card-ink,currentColor)]"
            : "text-current/55",
        )}
      />
      {count}
    </Button>
  )
}

/** The name a card opened whole wears for its view transition, shown copy by copy. */
const LIFTED_NAME = "card-full"

/** A card opened whole: which one (`opened`), whether it stands open, and the props
 *  its source card wears so the view transition knows where it comes from and goes
 *  back to. One at a time. */
export function useCardView<ID>() {
  const [lifted, setLifted] = React.useState<ID | null>(null)
  const [open, setOpen] = React.useState(false)
  const move = (f: () => void) => {
    if (!document.startViewTransition) {
      f()
      return null
    }
    return document.startViewTransition(() => flushSync(f))
  }
  return {
    /** The card opened, or being carried back. */
    opened: lifted,
    open,
    show: (id: ID) => {
      // The source wears the name first, then hands it to the open copy.
      flushSync(() => setLifted(id))
      move(() => setOpen(true))
    },
    hide: () => {
      const t = move(() => setOpen(false))
      if (!t) setLifted(null)
      else void t.finished.then(() => setLifted(null))
    },
    /** What the card `id` wears in its place: the name while it is the one opened
     *  and not open, so it is where the open card comes from and goes back to. */
    source: (id: ID): React.CSSProperties | undefined =>
      id === lifted && !open ? { viewTransitionName: LIFTED_NAME } : undefined,
  }
}

/** The card opened whole, over the page dimmed and blurred: a card of its own, its hue
 *  and material the source's, as wide as reading wants and as tall as what it holds
 *  (then it scrolls), its ✕ at its corner; esc and a press outside put it back.
 *  `title` names it for a screen reader (and is its heading). */
export function CardView<ID>({
  view,
  title,
  hue,
  material,
  children,
}: {
  view: ReturnType<typeof useCardView<ID>>
  title: string
  hue?: string
  material?: Material
  children: React.ReactNode
}) {
  return (
    <Dialog
      open={view.open}
      onOpenChange={(next) => {
        if (!next) view.hide()
      }}
    >
      <DialogContent
        bare
        backdrop="bg-black/50 backdrop-blur-sm"
        className="top-[10vh] w-[min(36rem,calc(100vw-2rem))] translate-y-0"
      >
        <Card
          hue={hue}
          material={material}
          padding="regular"
          lifted
          style={view.open ? { viewTransitionName: LIFTED_NAME } : undefined}
          // edge: its foot is the card's band, the end of the card itself, which a
          // fade would fade; what is taller than the window scrolls under its head.
          className="max-h-[80vh] overflow-auto"
        >
          <div className="flex items-start justify-between gap-4">
            <DialogTitle className="font-semibold text-base">
              {title}
            </DialogTitle>
            <DialogClose
              render={
                <Button
                  variant="ghost"
                  size="icon-sm"
                  corners="pill"
                  aria-label="close"
                  className="-mt-1 -mr-1"
                />
              }
            >
              <X />
            </DialogClose>
          </div>
          {children}
        </Card>
      </DialogContent>
    </Dialog>
  )
}