Card
A content container. Card is the clearest demonstration of the context system: the same component is a bordered, flat panel in product and a borderless, softly raised content block in marketing.
Playground
Change a prop and watch it. The code below follows along.
Live
Open full cardInstall and import
Card is in @dovetail-ds/react. Install it with React, if you haven't:
npm install @dovetail-ds/react react react-dom
import { Card } from "@dovetail-ds/react";
Import @dovetail-ds/react/styles.css once at your app's root, then your theme after it. Setup covers the stylesheets, fonts and dark mode.
Guidelines
A content container. Card is the clearest demonstration of the context system: the same component is a bordered, flat panel in product and a borderless, softly raised content block in marketing.
Use it when
- Grouping related content that could stand alone: a plan, a setting group, a dashboard panel, a feature block.
Don't use it when
- Everything on the page is a card. Nesting cards inside cards means neither is doing any work. Use
Stackand aDivider. - It is a list of similar rows. Use one container with row borders.
Example
<Card
eyebrow="Best value"
title="Team"
description="Everything in Starter, plus shared workspaces."
footer={<Button fullWidth>Choose Team</Button>}
/>
Product vs marketing
Set by the context, not by a prop.
| Product | Marketing | |
|---|---|---|
| Padding | 16px | 32px |
| Border | 1px | none |
| Elevation | 0, raising to 1 | 0, raising to 2 |
| Radius | container (8px) | overlay (12px) |
If a card needs different treatment inside one context, that is a Tier 3 override on a wrapper, not a new prop.
Tokens
--dt-card-*. Override these to restyle every card; override --dt-surface-raised to move all raised surfaces together.
As a link
Pass href and the whole card goes there. The title becomes the one link a screen reader announces and a keyboard reaches; a copy of it, hidden from assistive technology, stretches over the card so a click anywhere lands. Put a real button in footer: it sits above the stretched link and keeps its own click.
<Card href="/trips/ridge" title="Ridge loop" description="Four days, three huts." footer={<Button size="sm" variant="secondary">Save</Button>} />
Over other content
surface="glass" makes the card the overlay surface, translucent, with --dt-backdrop-glass blurring whatever is behind it: a feed scrolling under it, a map, a soft gradient. It follows the colour mode. surface="glass-inverse" is dark in both modes, for a card over a photograph, and puts the dark class on the card so its title, text and any buttons inside read light on dark. Check the text against the real image behind it, as guidelines/accessibility.md describes.
<Card surface="glass-inverse" title="Recovery" description="Your body is ready for a harder session." />
With a picture behind it
background fills the card with an image and backgroundVideo with a muted, looping video, which doesn't autoplay for someone who prefers reduced motion (they see background as its still). The card is then dark in both colour modes: it scopes .dark, holds at least --dt-card-media-min-height, and sets its content at the bottom over scrim.
scrim="gradient"(the default) darkens from the bottom, where the text sits.soliddarkens the whole card.noneis only for a picture already dark where the text lands.onMediasets the text:lightis the scrim's near-white,whiteis pure white for large type that would read as grey, andprimarysets the title in a light brand step. Secondary text stays a quieter light either way.
<Card background="/img/ridge.jpg" eyebrow="Guide" title="Ridge loop" description="Four days, three huts." onMedia="white" />
<Card backgroundVideo="/video/surf.mp4" background="/img/surf.jpg" scrim="solid" title="Morning session" onMedia="primary" />
The picture is decorative: say what matters about it in the title or description. Measure the text against the real picture, as guidelines/accessibility.md describes; a scrim is a floor, not a guarantee.
Tokens: --dt-card-media-fg, --dt-card-media-fg-secondary, --dt-card-media-fg-strong, --dt-card-media-fg-brand, --dt-card-media-border and --dt-card-media-min-height, aliasing --dt-text-on-scrim* and --dt-size-media-min.
Accessibility
interactive is visual only. A clickable card needs a real control inside it, href, or as="button" with an accessible name. A div with onClick is not keyboard operable.
Content
Eyebrow is two or three words. Title is a noun phrase. Description is one sentence.
Props
The typed contract, from Card.d.ts.
import * as React from "react";
/**
* Content container. The most context-sensitive component in the system.
*/
export interface CardProps extends Omit<React.HTMLAttributes<HTMLElement>, "title"> {
/** Uppercase label above the title. */
eyebrow?: string;
title?: React.ReactNode;
/** Secondary copy below the title. */
description?: React.ReactNode;
/** Image or media slot, rendered above everything. */
media?: React.ReactNode;
/** Action row at the bottom. */
footer?: React.ReactNode;
/** Raises elevation on hover and sets a pointer cursor. */
/**
* Makes the whole card a link. The title becomes the link a screen reader
* hears, and a copy of it stretches over the card for a pointer. Buttons in
* `footer` stay clickable above it; put interactive content there, not in
* children. Implies the interactive hover lift.
*/
/**
* raised is the card surface. glass and glass-strong are the overlay made
* translucent, blurring what is behind, for a card over a feed or a map;
* they follow the colour mode. glass-inverse is dark in both modes, for a
* card over a photograph, and scopes dark mode so its contents read light.
* @default "raised"
*/
surface?: "raised" | "glass" | "glass-strong" | "glass-inverse";
/**
* An image URL to fill the card behind its content. The card becomes dark
* in both colour modes (it scopes `.dark`, like `glass-inverse`), holds at
* least `--dt-card-media-min-height`, and sets its content at the bottom
* over a scrim. The picture is decorative; put what it shows in the title
* or description. Also the poster for `backgroundVideo`.
*/
background?: string;
/**
* A video URL to fill the card instead: muted, looping and inline. It does
* not autoplay for someone who prefers reduced motion; they see
* `background` as a still.
*/
backgroundVideo?: string;
/** Which part of the picture stays in frame, as CSS `object-position`. @default "center" */
backgroundPosition?: string;
/**
* The wash between the picture and the text. gradient darkens from the
* bottom, where the text sits; solid darkens the whole card; none is for a
* picture that is already dark where the text lands. Check the contrast
* against the real picture either way.
* @default "gradient"
*/
scrim?: "gradient" | "solid" | "none";
/**
* The text colour over a picture. light is the scrim's near-white; white is
* pure white, for large type that would otherwise read as grey; primary
* sets the title in a light brand step. Secondary text stays a quieter
* light in every case.
* @default "light"
*/
onMedia?: "light" | "white" | "primary";
href?: string;
interactive?: boolean;
/** Primary-colour border and tinted background for a chosen option. */
selected?: boolean;
/** @default "div" */
as?: keyof React.JSX.IntrinsicElements;
children?: React.ReactNode;
}
export declare function Card(props: CardProps): React.JSX.Element;
Tokens it reads
Every custom property Card.jsx resolves, read from the source: 18 component, 23 semantic. Component tier first, because that is the one to reach for.
- component Its own tier. Override one of these and only this component moves.
- semantic Shared roles. Override one and everything using that role moves with it.
| Token | Tier | Declared as |
|---|---|---|
--dt-card-bg | component | var(--dt-surface-raised) |
--dt-card-border-color | component | var(--dt-border-default) |
--dt-card-border-width | component | var(--dt-border-width-default) |
--dt-card-elevation | component | var(--dt-elevation-0) |
--dt-card-elevation-hover | component | var(--dt-elevation-1) |
--dt-card-fg | component | var(--dt-text-primary) |
--dt-card-gap | component | var(--dt-space-stack-sm) |
--dt-card-media-border | component | var(--dt-border-glass-inverse) |
--dt-card-media-fg | component | var(--dt-text-on-scrim) |
--dt-card-media-fg-brand | component | var(--dt-text-on-scrim-brand) |
--dt-card-media-fg-secondary | component | var(--dt-text-on-scrim-secondary) |
--dt-card-media-fg-strong | component | var(--dt-text-on-scrim-strong) |
--dt-card-media-min-height | component | var(--dt-size-media-min) |
--dt-card-padding | component | var(--dt-space-inset-lg) |
--dt-card-radius | component | var(--dt-radius-container) |
--dt-card-selected-bg | component | var(--dt-surface-selected) |
--dt-card-selected-border | component | var(--dt-border-selected) |
--dt-card-transition | component | var(--dt-motion-micro) |
--dt-backdrop-glass | semantic | saturate(1.6) blur(var(--dt-blur-glass)) |
--dt-border-glass | semantic | color-mix(in oklab, var(--dt-text-primary) 10%, transparent) |
--dt-border-glass-inverse | semantic | color-mix(in oklab, var(--dt-color-neutral-050) 14%, transparent) |
--dt-scrim-bottom | semantic | linear-gradient(to top, color-mix(in oklab, var(--dt-color-neutral-950) 80%, transparent), color-mix(in oklab, var(--dt-color-neutral-950) 40%, transparent) 45%, transparent) |
--dt-scrim-full | semantic | color-mix(in oklab, var(--dt-color-neutral-950) 55%, transparent) |
--dt-space-stack-xs | semantic | var(--dt-dim-2) |
--dt-surface-glass | semantic | color-mix(in oklab, var(--dt-surface-overlay) 72%, transparent) |
--dt-surface-glass-inverse | semantic | color-mix(in oklab, var(--dt-color-neutral-950) 58%, transparent) |
--dt-surface-glass-strong | semantic | color-mix(in oklab, var(--dt-surface-overlay) 88%, transparent) |
--dt-text-body-sm-family | semantic | var(--dt-font-family-sans) |
--dt-text-body-sm-line | semantic | var(--dt-line-height-sm) |
--dt-text-body-sm-size | semantic | var(--dt-font-size-sm) |
--dt-text-eyebrow-family | semantic | var(--dt-font-family-secondary) |
--dt-text-eyebrow-line | semantic | var(--dt-line-height-2xs) |
--dt-text-eyebrow-size | semantic | var(--dt-font-size-2xs) |
--dt-text-eyebrow-tracking | semantic | var(--dt-tracking-wider) |
--dt-text-eyebrow-weight | semantic | var(--dt-font-weight-semibold) |
--dt-text-heading-sm-family | semantic | var(--dt-font-family-sans) |
--dt-text-heading-sm-line | semantic | var(--dt-line-height-xl) |
--dt-text-heading-sm-size | semantic | var(--dt-font-size-xl) |
--dt-text-heading-sm-tracking | semantic | var(--dt-tracking-snug) |
--dt-text-heading-sm-weight | semantic | var(--dt-font-weight-medium) |
--dt-text-secondary | semantic | var(--dt-color-neutral-600) |
Source
React only, no dependencies, every value a token.
import React from "react";
const GLASS = "var(--dt-backdrop-glass, saturate(1.4) blur(16px))";
/* Glass surfaces for a card that sits over other content. Glass follows the
colour mode; inverse glass is dark in both, for a card over a photograph,
and scopes dark mode so everything inside it reads light on dark. */
const SURFACES = {
glass: { bg: "var(--dt-surface-glass)", bd: "var(--dt-border-glass)" },
"glass-strong": { bg: "var(--dt-surface-glass-strong)", bd: "var(--dt-border-glass)" },
"glass-inverse": { bg: "var(--dt-surface-glass-inverse)", bd: "var(--dt-border-glass-inverse)" },
};
/* A card over a photograph or a video. The scrim fades up from the bottom,
where the text sits, or washes the whole card; "none" is for a picture
that is already dark where the text lands. */
const SCRIMS = {
gradient: "var(--dt-scrim-bottom)",
solid: "var(--dt-scrim-full)",
none: "none",
};
/* What the text over a picture is set in: the scrim's light text, pure white
for large type that must not read as grey, or a light brand step on the
title. Secondary text stays a quieter light in every case. */
const ON_MEDIA = {
light: { fg: "var(--dt-card-media-fg)", title: "var(--dt-card-media-fg)" },
white: { fg: "var(--dt-card-media-fg-strong)", title: "var(--dt-card-media-fg-strong)" },
primary: { fg: "var(--dt-card-media-fg)", title: "var(--dt-card-media-fg-brand)" },
};
function prefersReducedMotion() {
return typeof window !== "undefined" && !!window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
export function Card({ eyebrow, title, description, media, footer, href, interactive = false, selected = false, surface = "raised", background, backgroundVideo, backgroundPosition = "center", scrim = "gradient", onMedia = "light", as: Tag = "div", className, children, style, ...rest }) {
const [hover, setHover] = React.useState(false);
/* The video starts from an effect rather than an autoPlay attribute, so the
server and the browser render the same markup; reduced motion leaves it on
its poster. It is muted and inline, which browsers let play unprompted. */
const video = React.useRef(null);
React.useEffect(() => {
const v = video.current;
if (!v || prefersReducedMotion()) return;
const playing = v.play();
if (playing && playing.catch) playing.catch(() => {});
}, [backgroundVideo]);
const linked = !!href;
const lift = interactive || linked;
const glass = SURFACES[surface];
/* With a picture behind it the card is dark in both colour modes, so it
scopes dark mode the way inverse glass does: a Button or Badge in the
footer reads light on dark without being told. */
const pictured = !!(background || backgroundVideo);
const tone = ON_MEDIA[onMedia] || ON_MEDIA.light;
if (pictured) {
const text = { position: "relative", zIndex: 1 };
return (
<Tag
className={["dark", className].filter(Boolean).join(" ")}
onMouseEnter={() => lift && setHover(true)}
onMouseLeave={() => lift && setHover(false)}
style={{
position: "relative", overflow: "hidden", isolation: "isolate",
display: "flex", flexDirection: "column", justifyContent: "flex-end", gap: "var(--dt-card-gap)",
minHeight: "var(--dt-card-media-min-height)",
background: "var(--dt-surface-glass-inverse)",
color: tone.fg,
border: `var(--dt-card-border-width) solid ${selected ? "var(--dt-card-selected-border)" : "var(--dt-card-media-border)"}`,
borderRadius: "var(--dt-card-radius)",
padding: "var(--dt-card-padding)",
boxShadow: hover ? "var(--dt-card-elevation-hover)" : "var(--dt-card-elevation)",
transition: "box-shadow var(--dt-card-transition), border-color var(--dt-card-transition)",
cursor: lift ? "pointer" : undefined,
...style,
}}
{...rest}
>
<div aria-hidden="true" style={{ position: "absolute", inset: 0, zIndex: 0 }}>
{backgroundVideo ? (
<video ref={video} src={backgroundVideo} poster={background} muted loop playsInline preload="metadata" tabIndex={-1}
style={{ width: "100%", height: "100%", objectFit: "cover", objectPosition: backgroundPosition, display: "block" }} />
) : (
<img src={background} alt="" loading="lazy" style={{ width: "100%", height: "100%", objectFit: "cover", objectPosition: backgroundPosition, display: "block" }} />
)}
{scrim !== "none" && <div style={{ position: "absolute", inset: 0, background: SCRIMS[scrim] || SCRIMS.gradient }} />}
</div>
{media && <div style={text}>{media}</div>}
{eyebrow && (
<span style={{
...text,
fontFamily: "var(--dt-text-eyebrow-family)", fontSize: "var(--dt-text-eyebrow-size)",
lineHeight: "var(--dt-text-eyebrow-line)", fontWeight: "var(--dt-text-eyebrow-weight)",
letterSpacing: "var(--dt-text-eyebrow-tracking)", textTransform: "uppercase",
color: "var(--dt-card-media-fg-secondary)",
}}>{eyebrow}</span>
)}
{title && (
<span style={{
...text,
fontFamily: "var(--dt-text-heading-sm-family)", fontSize: "var(--dt-text-heading-sm-size)",
lineHeight: "var(--dt-text-heading-sm-line)", fontWeight: "var(--dt-text-heading-sm-weight)",
letterSpacing: "var(--dt-text-heading-sm-tracking)", color: tone.title,
}}>{linked ? <a href={href} style={{ color: "inherit", textDecoration: hover ? "underline" : "none", textUnderlineOffset: 2 }}>{title}</a> : title}</span>
)}
{description && (
<span style={{
...text,
fontFamily: "var(--dt-text-body-sm-family)", fontSize: "var(--dt-text-body-sm-size)",
lineHeight: "var(--dt-text-body-sm-line)", color: onMedia === "white" ? tone.fg : "var(--dt-card-media-fg-secondary)",
}}>{description}</span>
)}
{children && <div style={text}>{children}</div>}
{linked && <a href={href} aria-hidden="true" tabIndex={-1} style={{ position: "absolute", inset: 0, zIndex: 2, borderRadius: "inherit" }} />}
{footer && <div style={{ marginTop: "var(--dt-space-stack-xs)", position: "relative", zIndex: 3 }}>{footer}</div>}
</Tag>
);
}
return (
<Tag
className={[surface === "glass-inverse" ? "dark" : null, className].filter(Boolean).join(" ") || undefined}
onMouseEnter={() => lift && setHover(true)}
onMouseLeave={() => lift && setHover(false)}
style={{
display: "flex", flexDirection: "column", gap: "var(--dt-card-gap)",
background: selected ? "var(--dt-card-selected-bg)" : glass ? glass.bg : "var(--dt-card-bg)",
backdropFilter: glass ? GLASS : undefined,
WebkitBackdropFilter: glass ? GLASS : undefined,
color: "var(--dt-card-fg)",
border: `var(--dt-card-border-width) solid ${selected ? "var(--dt-card-selected-border)" : glass ? glass.bd : "var(--dt-card-border-color)"}`,
borderRadius: "var(--dt-card-radius)",
padding: "var(--dt-card-padding)",
boxShadow: glass ? "none" : hover ? "var(--dt-card-elevation-hover)" : "var(--dt-card-elevation)",
transition: "box-shadow var(--dt-card-transition), border-color var(--dt-card-transition)",
cursor: lift ? "pointer" : undefined,
position: linked ? "relative" : undefined,
...style,
}}
{...rest}
>
{media}
{eyebrow && (
<span style={{
fontFamily: "var(--dt-text-eyebrow-family)", fontSize: "var(--dt-text-eyebrow-size)",
lineHeight: "var(--dt-text-eyebrow-line)", fontWeight: "var(--dt-text-eyebrow-weight)",
letterSpacing: "var(--dt-text-eyebrow-tracking)", textTransform: "uppercase",
color: "var(--dt-text-secondary)",
}}>{eyebrow}</span>
)}
{title && (
<span style={{
fontFamily: "var(--dt-text-heading-sm-family)", fontSize: "var(--dt-text-heading-sm-size)",
lineHeight: "var(--dt-text-heading-sm-line)", fontWeight: "var(--dt-text-heading-sm-weight)",
letterSpacing: "var(--dt-text-heading-sm-tracking)",
}}>{linked ? <a href={href} style={{ color: "inherit", textDecoration: hover ? "underline" : "none", textUnderlineOffset: 2 }}>{title}</a> : title}</span>
)}
{description && (
<span style={{
fontFamily: "var(--dt-text-body-sm-family)", fontSize: "var(--dt-text-body-sm-size)",
lineHeight: "var(--dt-text-body-sm-line)", color: "var(--dt-text-secondary)",
}}>{description}</span>
)}
{children}
{/* The title is the one link a screen reader hears. This copy stretches
the same target over the whole card for a pointer, and sits under the
footer so a real button there still gets its own click. */}
{linked && <a href={href} aria-hidden="true" tabIndex={-1} style={{ position: "absolute", inset: 0, borderRadius: "inherit" }} />}
{footer && <div style={{ marginTop: "var(--dt-space-stack-xs)", position: linked ? "relative" : undefined, zIndex: linked ? 1 : undefined }}>{footer}</div>}
</Tag>
);
}
In context
Card also appears on the display reference cards, alongside the rest of its family.