Layout
Card
A bordered container for one item, with header, body, footer and media slots, optionally one big link.
- Built on
:has()Stretched link pseudo-element- Client JavaScript
- None
- Works without React
- Yes, as HTML and CSS
- Source, gzipped
- 2.1 kB
- Dependencies
- React
Billing service
Deployed 2 h ago to eu-west-1
Try its options
Loading its options.
Code
import { Button } from "@/components/equip/button";
import { Card, CardFooter, CardHeader } from "@/components/equip/card";
export default function Demo() {
return (
<Card as="article" padding="s" style={{ width: 208 }}>
<CardHeader title="Billing service" description="Deployed 2 h ago to eu-west-1" />
<CardFooter>
<span>3 open issues</span>
<Button size="s" variant="outline">
Open
</Button>
</CardFooter>
</Card>
);
}Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<article style="width:208px" class="eq-card" data-padding="s" data-tone="surface">
<div class="eq-card-header">
<div class="eq-card-heading">
<h3 class="eq-card-title">Billing service</h3>
<p class="eq-card-description">Deployed 2 h ago to eu-west-1</p>
</div>
</div>
<div class="eq-card-footer">
<span>3 open issues</span>
<button type="button" class="eq-button" data-variant="outline" data-size="s">
<span class="eq-button-label">Open</span>
</button>
</div>
</article>Examples
Whole card as a link
The title is the only link; its pseudo-element covers the card, so the Star button still takes its own clicks.
Checkout
Next.js, 14 contributors
Ledger API
Go, 6 contributors
Code
import { Button } from "@/components/equip/button";
import { Card, CardFooter, CardHeader } from "@/components/equip/card";
const projects = [
{ name: "Checkout", meta: "Next.js, 14 contributors", href: "#checkout", stars: 128 },
{ name: "Ledger API", meta: "Go, 6 contributors", href: "#ledger-api", stars: 64 },
];
export default function Example() {
return (
<ul
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))",
gap: 12,
margin: 0,
padding: 0,
}}
>
{projects.map((project) => (
<Card as="li" key={project.name} interactive>
<CardHeader title={project.name} description={project.meta} href={project.href} />
<CardFooter>
<span>{project.stars} stars</span>
<Button size="s" variant="ghost">
Star
</Button>
</CardFooter>
</Card>
))}
</ul>
);
}HTMLworks in any framework, with the part's CSS
<ul style="display:grid;grid-template-columns:repeat(auto-fill, minmax(200px, 1fr));gap:12px;margin:0;padding:0">
<li class="eq-card" data-padding="m" data-tone="surface" data-interactive="true">
<div class="eq-card-header">
<div class="eq-card-heading">
<h3 class="eq-card-title">
<a class="eq-card-link" href="#checkout">Checkout</a>
</h3>
<p class="eq-card-description">Next.js, 14 contributors</p>
</div>
</div>
<div class="eq-card-footer">
<span>128 stars</span>
<button type="button" class="eq-button" data-variant="ghost" data-size="s">
<span class="eq-button-label">Star</span>
</button>
</div>
</li>
<li class="eq-card" data-padding="m" data-tone="surface" data-interactive="true">
<div class="eq-card-header">
<div class="eq-card-heading">
<h3 class="eq-card-title">
<a class="eq-card-link" href="#ledger-api">Ledger API</a>
</h3>
<p class="eq-card-description">Go, 6 contributors</p>
</div>
</div>
<div class="eq-card-footer">
<span>64 stars</span>
<button type="button" class="eq-button" data-variant="ghost" data-size="s">
<span class="eq-button-label">Star</span>
</button>
</div>
</li>
</ul>With media
CardMedia bleeds over the padding and clips to the card's corners one pixel inside the border.
Quarterly usage report
Generated 1 Oct for Larder
API calls rose 18 percent; storage stayed flat at 412 GB.
Code
import { Button } from "@/components/equip/button";
import { Card, CardBody, CardFooter, CardHeader, CardMedia } from "@/components/equip/card";
export default function Example() {
return (
<Card as="article" style={{ maxWidth: 300 }}>
<CardMedia>
<div
aria-hidden="true"
style={{
height: 96,
background:
"linear-gradient(135deg, var(--eq-tint-strong), var(--eq-tint) 60%, var(--eq-surface))",
}}
/>
</CardMedia>
<CardHeader title="Quarterly usage report" description="Generated 1 Oct for Larder" />
<CardBody>
<p>API calls rose 18 percent; storage stayed flat at 412 GB.</p>
</CardBody>
<CardFooter>
<span>PDF, 2.4 MB</span>
<Button size="s" variant="outline">
Download
</Button>
</CardFooter>
</Card>
);
}HTMLworks in any framework, with the part's CSS
<article style="max-width:300px" class="eq-card" data-padding="m" data-tone="surface">
<div class="eq-card-media">
<div aria-hidden="true" style="height:96px;background:linear-gradient(135deg, var(--eq-tint-strong), var(--eq-tint) 60%, var(--eq-surface))"></div>
</div>
<div class="eq-card-header">
<div class="eq-card-heading">
<h3 class="eq-card-title">Quarterly usage report</h3>
<p class="eq-card-description">Generated 1 Oct for Larder</p>
</div>
</div>
<div class="eq-card-body">
<p>API calls rose 18 percent; storage stayed flat at 412 GB.</p>
</div>
<div class="eq-card-footer">
<span>PDF, 2.4 MB</span>
<button type="button" class="eq-button" data-variant="outline" data-size="s">
<span class="eq-button-label">Download</span>
</button>
</div>
</article>Tones
Only raised carries a shadow; save it for a card that floats above the page, like a dragged item.
Surface
The default, on the page background.
Outline
Lighter, for dense grids.
Raised
For a card that floats.
Code
import { Button } from "@/components/equip/button";
import { Card, CardBody, CardHeader } from "@/components/equip/card";
export default function Example() {
return (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))", gap: 12 }}>
<Card padding="s">
<CardHeader title="Surface" description="The default, on the page background." />
</Card>
<Card padding="s" tone="outline">
<CardHeader title="Outline" description="Lighter, for dense grids." />
</Card>
<Card padding="s" tone="raised">
<CardHeader
title="Raised"
description="For a card that floats."
actions={
<Button size="s" variant="ghost" aria-label="Dismiss">
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</Button>
}
/>
<CardBody>Dragging a task lifts it here.</CardBody>
</Card>
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(180px, 1fr));gap:12px">
<div class="eq-card" data-padding="s" data-tone="surface">
<div class="eq-card-header">
<div class="eq-card-heading">
<h3 class="eq-card-title">Surface</h3>
<p class="eq-card-description">The default, on the page background.</p>
</div>
</div>
</div>
<div class="eq-card" data-padding="s" data-tone="outline">
<div class="eq-card-header">
<div class="eq-card-heading">
<h3 class="eq-card-title">Outline</h3>
<p class="eq-card-description">Lighter, for dense grids.</p>
</div>
</div>
</div>
<div class="eq-card" data-padding="s" data-tone="raised">
<div class="eq-card-header">
<div class="eq-card-heading">
<h3 class="eq-card-title">Raised</h3>
<p class="eq-card-description">For a card that floats.</p>
</div>
<div class="eq-card-actions">
<button aria-label="Dismiss" type="button" class="eq-button" data-variant="ghost" data-size="s">
<span class="eq-button-label"><svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8" /></svg></span>
</button>
</div>
</div>
<div class="eq-card-body">Dragging a task lifts it here.</div>
</div>
</div>Props
| Prop | Type | Default | What it does |
|---|---|---|---|
as | "div" | "article" | "section" | "li" | "div" | The root element. article for a self-contained item, li inside a list. |
interactive | boolean | false | Stretches the CardHeader link over the whole card, lifts it on hover and presses it on click. |
padding | "none" | "s" | "m" | "l" | "m" | Inner spacing; also the gap between the card's pieces. |
tone | "surface" | "raised" | "outline" | "surface" | surface sits on the page, outline is lighter, raised adds the shadow for a card that floats. |
...rest | ComponentProps<"div"> | none | Every native attribute of the root element. |
CardHeader | title, description?, actions?, href? | none | An h3 title, an optional line of meta below it and an actions slot at the end. href turns the title into the card's link. |
CardBody | ComponentProps<"div"> | none | The main content, in secondary ink. |
CardFooter | ComponentProps<"div"> | none | A row under a hairline, pushed to the bottom when cards in a grid share a height. |
CardMedia | ComponentProps<"div"> | none | A top slot that bleeds to the edges and clips to the card's corners. Takes an image, video or any element. |
Anatomy
| root | .eq-card | The element chosen by as. |
|---|---|---|
| media | .eq-card-media | Negative margins cancel the padding; corners are the card radius less the border. |
| header | .eq-card-header | |
| heading | .eq-card-heading | Wraps the title and description so actions stay at the end. |
| title | .eq-card-title | An h3. |
| link | .eq-card-link | Rendered when CardHeader has href. On an interactive card its ::after covers the card. |
| description | .eq-card-description | |
| actions | .eq-card-actions | |
| body | .eq-card-body | |
| footer | .eq-card-footer |
Keyboard
| Tab | On an interactive card, focuses the title link, then any controls inside. The ring is drawn around the whole card. |
|---|---|
| Enter | Follows the card's link. |
Notes
- An interactive card is one real link, the title, so screen readers announce it by the title and not by every word on the card.
- Buttons and links inside an interactive card sit above the stretched link, so they keep their own clicks and focus.
- Do not wrap a card in a link or put a link around its content: nested interactive elements are invalid and confuse assistive tech.
- The title is an h3, which suits a card in a section headed by an h2; there is no prop to change the level.
- Renders on the server with no client JavaScript.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/card.tsx82 lines
import type { ComponentProps, ReactNode } from "react";
import "./card.css";
export type CardProps = ComponentProps<"div"> & {
/** The element at the root. Use article for a self-contained item, li inside a list. */
as?: "div" | "article" | "section" | "li";
/** Makes the whole card one link target. Pair it with CardHeader href. */
interactive?: boolean;
padding?: "none" | "s" | "m" | "l";
/** raised adds the shadow; use it only for a card that floats above the page. */
tone?: "surface" | "raised" | "outline";
};
export function Card({
as = "div",
interactive = false,
padding = "m",
tone = "surface",
className,
...rest
}: CardProps) {
// The root changes element but never its attributes, so the div typing holds for all four.
const Root = as as "div";
return (
<Root
{...rest}
className={["eq-card", className].filter(Boolean).join(" ")}
data-padding={padding}
data-tone={tone}
data-interactive={interactive || undefined}
/>
);
}
export type CardHeaderProps = Omit<ComponentProps<"div">, "title"> & {
title: ReactNode;
description?: ReactNode;
/** Buttons or a menu at the end of the header. */
actions?: ReactNode;
/** Turns the title into a link. On an interactive card it stretches over the whole card. */
href?: string;
};
export function CardHeader({
title,
description,
actions,
href,
className,
...rest
}: CardHeaderProps) {
return (
<div {...rest} className={["eq-card-header", className].filter(Boolean).join(" ")}>
<div className="eq-card-heading">
<h3 className="eq-card-title">
{href ? (
<a className="eq-card-link" href={href}>
{title}
</a>
) : (
title
)}
</h3>
{description != null && <p className="eq-card-description">{description}</p>}
</div>
{actions != null && <div className="eq-card-actions">{actions}</div>}
</div>
);
}
export function CardBody({ className, ...rest }: ComponentProps<"div">) {
return <div {...rest} className={["eq-card-body", className].filter(Boolean).join(" ")} />;
}
export function CardFooter({ className, ...rest }: ComponentProps<"div">) {
return <div {...rest} className={["eq-card-footer", className].filter(Boolean).join(" ")} />;
}
/** A top slot that bleeds to the card's edges and clips to its radius. */
export function CardMedia({ className, ...rest }: ComponentProps<"div">) {
return <div {...rest} className={["eq-card-media", className].filter(Boolean).join(" ")} />;
}components/equip/card.css185 lines
@layer equip {
.eq-card {
--_pad: var(--eq-space-4);
--_radius: var(--eq-radius-l);
position: relative;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: var(--_pad);
min-width: 0;
margin: 0;
padding: var(--_pad);
border: 1px solid var(--eq-line);
border-radius: var(--_radius);
background: var(--eq-surface);
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-m) / 1.5 var(--eq-font);
list-style: none;
}
.eq-card[data-padding="none"] {
--_pad: 0px;
}
.eq-card[data-padding="s"] {
--_pad: var(--eq-space-3);
}
.eq-card[data-padding="l"] {
--_pad: var(--eq-space-5);
}
.eq-card[data-tone="raised"] {
background: var(--eq-raised);
box-shadow: var(--eq-shadow);
-webkit-backdrop-filter: var(--eq-blur);
backdrop-filter: var(--eq-blur);
}
.eq-card[data-tone="outline"] {
background: transparent;
border-color: var(--eq-line-strong);
}
/* Media bleeds over the padding and keeps the card's corner, one pixel inside the border. */
.eq-card-media {
margin: calc(-1 * var(--_pad)) calc(-1 * var(--_pad)) 0;
overflow: hidden;
border-bottom: 1px solid var(--eq-line);
border-radius: calc(var(--_radius) - 1px) calc(var(--_radius) - 1px) 0 0;
}
.eq-card-media:not(:first-child) {
margin-top: 0;
border-top: 1px solid var(--eq-line);
border-radius: 0;
}
.eq-card-media > :is(img, video, picture, svg) {
display: block;
width: 100%;
height: auto;
}
.eq-card-header {
display: flex;
align-items: flex-start;
gap: var(--eq-space-3);
}
.eq-card-heading {
flex: 1;
min-width: 0;
}
.eq-card-title {
margin: 0;
font-size: var(--eq-text-m);
font-weight: var(--eq-weight-strong);
line-height: 1.4;
letter-spacing: -0.005em;
}
.eq-card-description {
margin: 0;
color: var(--eq-ink-2);
font-size: var(--eq-text-s);
}
.eq-card-actions {
flex: none;
display: flex;
gap: var(--eq-space-1);
}
.eq-card-body {
color: var(--eq-ink-2);
}
.eq-card-body > :first-child {
margin-top: 0;
}
.eq-card-body > :last-child {
margin-bottom: 0;
}
.eq-card-footer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--eq-space-2);
margin-top: auto;
padding-top: var(--_pad);
border-top: 1px solid var(--eq-line);
color: var(--eq-ink-2);
font-size: var(--eq-text-s);
}
.eq-card-link {
color: inherit;
text-decoration: none;
border-radius: var(--eq-radius-s);
}
.eq-card-link:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
@media (hover: hover) {
.eq-card-link:hover {
text-decoration: underline;
text-underline-offset: 0.2em;
}
}
/* Interactive: one link, stretched over the card by its pseudo-element. */
.eq-card[data-interactive] {
transition:
translate var(--eq-dur-move) var(--eq-spring),
scale var(--eq-dur) var(--eq-ease),
border-color var(--eq-dur) var(--eq-ease);
}
.eq-card[data-interactive] .eq-card-link::after {
content: "";
position: absolute;
/* 2px past the bottom edge, so the hover lift does not slide the card out from under the cursor. */
inset: 0 0 -2px;
border-radius: var(--_radius);
}
/* Nested controls sit above the stretched link, so they keep their own clicks. */
.eq-card[data-interactive] :is(button, input, select, textarea, summary, a:not(.eq-card-link), [tabindex]) {
position: relative;
z-index: 1;
}
.eq-card[data-interactive] .eq-card-link:focus-visible {
outline: none;
}
.eq-card[data-interactive]:has(.eq-card-link:focus-visible) {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
@media (hover: hover) {
.eq-card[data-interactive]:has(.eq-card-link:hover) {
translate: 0 -2px;
border-color: var(--eq-line-strong);
}
.eq-card[data-interactive] .eq-card-link:hover {
text-decoration: none;
}
}
.eq-card[data-interactive]:has(.eq-card-link:active) {
scale: var(--eq-press);
}
}