Card

How to build a card that groups what people need to know about one thing, that keyboard and screen reader users can move through, and that agents can check.

Cards group what people need to know about one thing, like a project, a product or a person, so they can scan a page of them and pick one. They look like a box with a picture in it, which is why they're so often built with only a mouse in mind. Think of a card wrapped in a link, which a screen reader reads out from its first word to its last as the link's name, a <div> with a click handler, which keyboard users can't reach at all, or a Save button nested inside that link, which HTML doesn't allow.

This page walks you through building one the right way. We'll look at:

User Interface (UI)

How a card looks, from its parts and what goes in each of them to how cards sit together, its states and its tokens.

User Experience (UX)

How a card behaves for every person, input and setting, so it's accessible, predictable, clear in every state and adaptive.

Developer Experience (DX)

How developers use the Card, with one consistent, typed API that composes with links, routers and other components, and needs no script.

Agentic Experience (AX)

How agents read, build and check a card, from its accessibility tree to its contract, its DOM and its tests.

At a glance

Every example on this page runs on our reference implementation, which does its part of every rule in the checklist, so feel free to tab through them as you read. The code under each example comes in React, Vue, Svelte, Angular, Solid, Astro and Vanilla, written for a Card with the same API in each. Pick yours from the Framework select above the sidebar. Vanilla is plain HTML and JavaScript, so it writes out the markup from the DOM contract.

A card is a container rather than a control, so most of what it does comes from the elements in it: a heading, a link when the whole card takes you somewhere, and Buttons for anything else people can do with it.

A general-purpose mechanical computer, first described in 1837.

By Charles Babbage
<Card
  heading="Analytical Engine"
  href="/projects/analytical-engine"
  eyebrow="Project"
  media={<img src="/projects/analytical-engine.jpg" alt="" />}
  actions={<Button size="sm" variant="outline">Save</Button>}
  footer="By Charles Babbage"
>
  <p>A general-purpose mechanical computer, first described in 1837.</p>
</Card>
AspectCard
Element<article>, or a <section>, <div> or <li> with as
Rolearticle, or listitem as an <li>
NameIts heading, which also names its link when the card is one
KeyboardTab to its link, then to its actions, and Enter to follow the link
StatesHover and focus, when it's a link
PartsMedia, a header with the heading and an eyebrow, body, actions and footer, of which only the header and its heading are always there
PatternNone in the APG, since an <article> and a link cover it
WCAG 2.21.1.1, 1.3.1, 1.3.2, 1.4.3, 1.4.11, 2.1.1, 2.4.4, 2.4.6, 2.4.7 and 4.1.2, among others

Checklist

Here's every rule in one place, along with where it comes from and how to check it. A card meets the standard when it meets every must, and each should is expected unless you have a good reason not to follow it.

The scope says who meets a rule. Component rules are met by the Card itself, so they're the ones to check when you build or review one. Usage rules are met by the pages that use it, like describing its images, so they're the ones to check when you review a screen. Both rules need the two to work together, like its heading: the Card renders one at the level you give it, as long as you give it a heading that says what the card is about.

The basis says where a rule comes from. About half the rules rest on WCAG 2.2, which we follow at level AA, and name the success criteria behind them, and one comes from HTML. The rest are Open Components rules, which are our own, like putting the heading first in the DOM. Where a rule goes further than WCAG asks at level AA, its basis says so: some come from level AAA, like keeping cards still for people who ask for less motion, and a rule that asks for more than its criterion says beyond, like the stretched link, where WCAG would let a card be a link in other ways.

You'll find the rules for each layer on its page:

Reference implementation

Our Vue 3 reference implementation does its part of every rule on this page, and powers every example on it. Feel free to copy it into your design system and change how it looks, but try to keep how it behaves. It reads the same theme tokens as the Button, so copy tokens.css from the Button's reference implementation into your theme too, and its examples put Buttons in actions.

The tests are in Card.test.ts. To run them, you'll need Vitest, set to environment: "jsdom" and globals: true, along with @testing-library/vue, @testing-library/user-event and @testing-library/jest-dom.

<script setup lang="ts">
export interface CardProps {
  /** The element it renders: `li` in a list of cards, or `section` or `div` for a card that isn't self-contained. */
  as?: "article" | "section" | "div" | "li";
  /** The heading, when it's plain text. The `heading` slot takes precedence. It isn't `title`, which is an HTML attribute that shows a tooltip. */
  heading?: string;
  /** The heading's level, which fits the page's outline: 3 under a section's `<h2>`. */
  headingLevel?: 2 | 3 | 4 | 5 | 6;
  /** Links the card through its heading, and stretches the link over the whole card. */
  href?: string;
}

const { as = "article", heading, headingLevel = 3, href } = defineProps<CardProps>();

defineSlots<{
  /** The body, like a sentence or two and some details. */
  default?: () => unknown;
  /** The heading, when it's more than plain text, or your router's link. A link here stretches over the card too. */
  heading?: () => unknown;
  /** A short line shown above the heading, like a category, which comes after it in the DOM. */
  eyebrow?: () => unknown;
  /** An image, shown at the top, which comes after the heading in the DOM. */
  media?: () => unknown;
  /** Buttons that act on the card, like Save, which sit above the stretched link and stay reachable with Tab. */
  actions?: () => unknown;
  /** A last line of details about the card, like who made it or when. */
  footer?: () => unknown;
}>();
</script>

<template>
  <!-- The heading comes first in the DOM, so heading navigation lands at the start of
       the card. The media and the eyebrow come after it, and CSS shows them above it. -->
  <component :is="as" class="card">
    <div data-slot="header">
      <component :is="`h${headingLevel}`" data-slot="heading">
        <a v-if="href" data-slot="link" :href="href">
          <slot name="heading">{{ heading }}</slot>
        </a>
        <slot v-else name="heading">{{ heading }}</slot>
      </component>
      <p v-if="$slots.eyebrow" data-slot="eyebrow">
        <slot name="eyebrow" />
      </p>
    </div>
    <div v-if="$slots.media" data-slot="media">
      <slot name="media" />
    </div>
    <div v-if="$slots.default" data-slot="body">
      <slot />
    </div>
    <div v-if="$slots.actions" data-slot="actions">
      <slot name="actions" />
    </div>
    <div v-if="$slots.footer" data-slot="footer">
      <slot name="footer" />
    </div>
  </component>
</template>

<style scoped>
/* The same layer order as Tailwind CSS. The card's styles go in `components`,
   so they win over the resets in `base`, and give way to utilities and to any
   style outside a layer, like a class of yours that sets its variables. */
@layer theme, base, components, utilities;

@layer components {
  .card {
    --card--padding: 1.25rem;
    --card--gap: 0.75rem;
    --card--radius: 0.75rem;
    /* The card's border is decoration: its heading says what it is, so it doesn't need 3:1. */
    --card--border-color: color-mix(in srgb, currentColor 20%, transparent);
    --card--background: transparent;
    --card--media--aspect-ratio: 16 / 9;

    /* The containing block of the stretched link. */
    position: relative;
    display: flex;
    flex-direction: column;
    gap: var(--card--gap);
    box-sizing: border-box;
    /* In a grid or a flex row, it can get narrower than its content, rather than overflow. */
    min-width: 0;
    margin: 0;
    padding: var(--card--padding);
    border: 1px solid var(--card--border-color);
    border-radius: var(--card--radius);
    background-color: var(--card--background);
    color: var(--color--neutral-text);
    font-family: inherit;
    font-size: 0.875rem;
    line-height: 1.5;
    /* A list item's marker, when it's an `li`. */
    list-style: none;
    /* Long words, like a web address, wrap rather than overflow the card. */
    overflow-wrap: break-word;
  }

  /* Parts. They have no borders of their own: a line between two of them is a separator's
     job, which a page adds where it needs one. No height is fixed anywhere, so the card
     grows with its text. */
  .card > [data-slot="header"] {
    display: flex;
    flex-direction: column;
    gap: 0.25rem;
  }
  /* The body takes the spare height, so actions and footers line up across a row of
     cards of one height. A card without a body gives it to its header instead. */
  .card > [data-slot="body"],
  .card:not(:has(> [data-slot="body"])) > [data-slot="header"] {
    flex-grow: 1;
  }
  .card > [data-slot="header"] > [data-slot="heading"] {
    margin: 0;
    color: inherit;
    font-size: 1rem;
    font-weight: 600;
    line-height: 1.375;
  }
  /* Shown above the heading, while screen readers read it after the heading. */
  .card > [data-slot="header"] > [data-slot="eyebrow"] {
    order: -1;
    margin: 0;
    /* The text colour at 70%, which has 4.5:1 against the page. */
    color: color-mix(in srgb, currentColor 70%, transparent);
    font-size: 0.75rem;
    font-weight: 500;
  }
  /* The media fills the top of the card, edge to edge, while it comes after the
     heading in the DOM. Its aspect ratio holds its space while the image loads. */
  .card > [data-slot="media"] {
    order: -1;
    margin-block-start: calc(-1 * var(--card--padding));
    margin-inline: calc(-1 * var(--card--padding));
    overflow: hidden;
    border-start-start-radius: calc(var(--card--radius) - 1px);
    border-start-end-radius: calc(var(--card--radius) - 1px);
    background-color: color-mix(in srgb, currentColor 8%, transparent);
    aspect-ratio: var(--card--media--aspect-ratio);
  }
  .card > [data-slot="media"] > :slotted(:is(img, picture, video, svg)) {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
  }
  .card > [data-slot="actions"] {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
  }
  .card > [data-slot="footer"] {
    /* The text colour at 70%, which has 4.5:1 against the page. */
    color: color-mix(in srgb, currentColor 70%, transparent);
    font-size: 0.75rem;
  }

  /* A link in the heading, the Card's own or one in the `heading` slot, stretches over
     the card with a pseudo-element, so pressing anywhere on the card follows it,
     while the link's name stays the heading alone. */
  .card > [data-slot="header"] > [data-slot="heading"] :deep(a[href]) {
    color: inherit;
    text-decoration: none;
    outline: none;
  }
  .card > [data-slot="header"] > [data-slot="heading"] :deep(a[href])::after {
    content: "";
    position: absolute;
    /* Over the border too, so the focus ring sits where it would around the card. */
    inset: -1px;
    border-radius: var(--card--radius);
  }
  /* Everything else people can press sits above the stretched link, so it does what
     it says rather than follow the link. */
  .card > [data-slot="header"] > [data-slot="eyebrow"] :deep(a[href]),
  .card > :is([data-slot="body"], [data-slot="actions"], [data-slot="footer"]) :deep(:is(a[href], button, input, select, textarea, summary, [tabindex])) {
    position: relative;
    z-index: 1;
  }

  /* States. The card only has any when it's a link. Focus is drawn around the whole
     card, by the link's pseudo-element, while an action shows its own focus ring. */
  .card > [data-slot="header"] > [data-slot="heading"] :deep(a[href]:focus-visible)::after {
    outline: 2px solid var(--color--focus);
    outline-offset: 2px;
  }
  @media (hover: hover) {
    /* Only while the pointer is over the link, so not over an action. */
    .card:has(> [data-slot="header"] > [data-slot="heading"] a[href]:hover) {
      --card--border-color: color-mix(in srgb, currentColor 45%, transparent);
    }
    .card > [data-slot="header"] > [data-slot="heading"] :deep(a[href]:hover) {
      text-decoration-line: underline;
      text-underline-offset: 0.2em;
    }
  }

  /* With more contrast, the border is drawn in the text colour at full strength, on
     hover too, so this repeats the hover rule's selector, which it comes after. */
  @media (prefers-contrast: more) {
    .card,
    .card:has(> [data-slot="header"] > [data-slot="heading"] a[href]:hover) {
      --card--border-color: currentColor;
    }
  }

  /* Forced colors mode keeps the border in a system colour, and the focus ring in Highlight. */
  @media (forced-colors: active) {
    .card > [data-slot="header"] > [data-slot="heading"] :deep(a[href]:focus-visible)::after {
      outline-color: Highlight;
    }
  }
}
</style>

Sources

Copyright © 2026