an item's parts on a card: its kind, its author, its counts, opening it whole
npx shadcn add @ag/item-cardAlready installed? Preview an update
npx shadcn add @ag/item-card --dry-runYour installed source stays unchanged until you update it. How updates work
- New: an item's parts on a
card, swift-utils'inkCardparts 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) andCardCount(a count a person acts on, vote's pill, the icon filled in the hue when it is theirs).useCardViewandCardViewopen a card whole (Ink'sinkFullView): 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, iscard's.
the demo above, verbatim
"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
"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>
)
}
