Card

import { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent, CardMedia } from '@hanzo/ui'

Default

A header with a title and description, a body, and a footer holding the actions: the shape most cards take.

StorageUsage across every bucket this month.
412 GB of 1 TB used.
export function Default() {
return (
<Card maxW={360}>
<CardHeader>
<CardTitle>Storage</CardTitle>
<CardDescription>Usage across every bucket this month.</CardDescription>
</CardHeader>
<CardContent>412 GB of 1 TB used.</CardContent>
<CardFooter gap="$2" justify="flex-end">
<Button variant="outline">Manage</Button>
<Button variant="primary">Upgrade</Button>
</CardFooter>
</Card>
)
}

With action

CardAction sits last in the header, flush with its right edge, for a control that acts on the whole card rather than on one row of it.

Team membersPeople with access to the acme workspace.
Mara OkaforJules BernardPriya Natarajan
export function WithAction() {
return (
<Card maxW={360}>
<CardHeader>
<CardTitle>Team members</CardTitle>
<CardDescription>
People with access to the acme workspace.
</CardDescription>
<CardAction>
<Button variant="ghost" size="sm">
Invite
</Button>
</CardAction>
</CardHeader>
<CardContent>
<YStack gap="$1">
<Text>Mara Okafor</Text>
<Text>Jules Bernard</Text>
<Text>Priya Natarajan</Text>
</YStack>
</CardContent>
</Card>
)
}

With media

CardMedia runs edge to edge above the header and holds its ratio before the image loads, so nothing below it moves when the bytes arrive.

Open notebook and a pen on a wooden desk
Field notebookDot grid, 192 pages, A5.
$18
export function WithMedia() {
return (
<Card maxW={320}>
<CardMedia ratio={16 / 10}>
<img src={COVER} alt="Open notebook and a pen on a wooden desk" />
</CardMedia>
<CardHeader>
<CardTitle>Field notebook</CardTitle>
<CardDescription>Dot grid, 192 pages, A5.</CardDescription>
</CardHeader>
<CardContent>$18</CardContent>
<CardFooter>
<Button variant="primary" width="100%">
Add to cart
</Button>
</CardFooter>
</Card>
)
}

Product

ItemScope and itemType on the card and itemProp on the pieces make one schema.org Product, which is what a search engine and a merchant feed read the name, image and price from.

Open notebook and a pen on a wooden desk
Field notebookDot grid, 192 pages, A5.
$18
export function Product() {
return (
<Card maxW={320} itemScope itemType="https://schema.org/Product">
<CardMedia>
<img src={COVER} alt="Open notebook and a pen on a wooden desk" itemProp="image" />
</CardMedia>
<CardHeader>
<CardTitle>
<span itemProp="name">Field notebook</span>
</CardTitle>
<CardDescription>Dot grid, 192 pages, A5.</CardDescription>
</CardHeader>
<CardContent>
<span itemProp="offers" itemScope itemType="https://schema.org/Offer">
<span itemProp="price" content="18">
$18
</span>
</span>
</CardContent>
<CardFooter>
<Button variant="primary" width="100%">
Add to cart
</Button>
</CardFooter>
</Card>
)
}

Interactive

The whole card is the control: focusable, activated by click, Enter or Space, lit on hover, so a choice among cards needs no button inside each.

Frankfurt12 ms from you
San Francisco48 ms from you
Singapore91 ms from you

Deploying to Frankfurt.

export function Interactive() {
const regions = [
{ id: "fra", city: "Frankfurt", latency: "12 ms" },
{ id: "sfo", city: "San Francisco", latency: "48 ms" },
{ id: "sin", city: "Singapore", latency: "91 ms" },
]
const [region, setRegion] = useState("fra")
return (
<YStack gap="$3">
<XStack flexWrap="wrap" gap="$3">
{regions.map((r) => (
<Card key={r.id} interactive width={200} aria-pressed={region === r.id} borderColor={region === r.id ? "$ink" : "$borderColor"} onPress={() => setRegion(r.id)} >
<CardHeader>
<CardTitle>{r.city}</CardTitle>
<CardDescription>{r.latency} from you</CardDescription>
</CardHeader>
</Card>
))}
</XStack>
<Paragraph color="$quiet">
Deploying to {regions.find((r) => r.id === region)?.city}.
</Paragraph>
</YStack>
)
}

Namespace

Every part hung off Card as Card.Media, Card.Header, Card.Title, Card.Meta, Card.Action, Card.Body and Card.Footer, the same components as the named exports, for code that reads better with one import.

Open notebook and a pen on a wooden desk
Weekly digestSent every Monday at 09:00.
The next issue covers 47 merged pull requests.
export function Namespace() {
return (
<Card maxW={360}>
<Card.Media ratio={21 / 9}>
<img src={COVER} alt="Open notebook and a pen on a wooden desk" />
</Card.Media>
<Card.Header>
<Card.Title>Weekly digest</Card.Title>
<Card.Meta>Sent every Monday at 09:00.</Card.Meta>
<Card.Action>
<Button variant="ghost" size="sm">
Edit
</Button>
</Card.Action>
</Card.Header>
<Card.Body>The next issue covers 47 merged pull requests.</Card.Body>
<Card.Footer gap="$2">
<Button variant="outline" size="sm">
Preview
</Button>
<Button variant="ghost" size="sm">
Unsubscribe
</Button>
</Card.Footer>
</Card>
)
}

Types

export type CardProps = ComponentProps<typeof CardFrame> & {
/**
* The whole card is the control. Makes it focusable, gives it the button role
* and the Enter/Space keys, and lights it on hover and focus.
*
* This exists so nobody wraps a Card in a <Button> again. A Button pins its
* own height — correctly, it is a control — and a card put inside one gets
* clipped to a 30px sliver. The affordance belongs on the surface, not around
* it.
*/
interactive?: boolean
} & Microdata
export type CardHeaderProps = ComponentProps<typeof HeaderFrame>
export type CardTitleProps = ComponentProps<typeof TitleFrame>
export type CardDescriptionProps = ComponentProps<typeof DescriptionFrame>
export type CardActionProps = ComponentProps<typeof ActionFrame>
export type CardContentProps = ComponentProps<typeof ContentFrame>
export type CardFooterProps = ComponentProps<typeof FooterFrame>
export type CardMediaProps = AspectRatioProps

Exports

CardCardHeaderCardFooterCardTitleCardActionCardDescriptionCardContentCardMediatype CardMediaProps