Card

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.

Next, let's look at how developers use the Card. It has one API, with the same names as every other component, it's typed end to end, and it needs no script at all, since everything it does is HTML and CSS.

import { Card } from "@/components/Card";

export function Projects({ projects }: { projects: Project[] }) {
  return (
    <>
      <h2>Projects</h2>
      <ul className="projects">
        {projects.map((project) => (
          <Card key={project.id} as="li" heading={project.name} href={`/projects/${project.id}`}>
            <p>{project.summary}</p>
          </Card>
        ))}
      </ul>
    </>
  );
}

Consistent

Once you've learned the Button's and the Input's APIs, you already know the Card's href and actions. These names mean the same thing everywhere in Open Components:

NameKindWhat it means
hrefPropThe component is a link, and this is where it goes
defaultSlotThe main content, between the component's tags
actionsSlotButtons that act on the component, after its main content

The rest of the Card's names are its own, and each one says what it is:

  • heading is the heading, and headingLevel its level. We didn't call it title, which is an HTML attribute that shows a tooltip, so in Angular, Astro or plain HTML, where an attribute stays on the element, every card would grow one. label is for controls, which a card isn't.
  • as picks the element, as it does in many component libraries. We recommend article as the default, since it's what most cards are.
  • media, eyebrow and footer are named after the parts they fill, so the slot and the data-slot it renders have the same name.

The Card also leaves a few props out, on purpose:

  • There's no variant or color. A card has no intent of its own, unlike a Button, and its look comes from its variables, which you set from a class.
  • There's no disabled. A card isn't a control, so there's nothing to disable. If its link isn't available, like a project that's been archived, leave out the href and say why in the body.
  • There's no selected. A card people pick, like a plan, is a choice, so it belongs to a form control: a Radio Group, on our Roadmap, when people pick one, or a Checkbox Group when they pick any number, each with the keyboard and the states that a choice needs.
  • There's no onClick. The Card never listens for clicks, since its link does that job, and its actions are Buttons with listeners of their own.

Type-safe

Props and slots are typed, so your editor can autocomplete them and your type checker (tsc, vue-tsc, svelte-check, ngc or astro check) catches mistakes like headingLevel={1}, which would give a card the page's own heading level.

PropTypeDefaultDescription
as"article" | "section" | "div" | "li""article"The element it renders
headingstringThe heading, when it's plain text. The heading slot takes precedence
headingLevel2 | 3 | 4 | 5 | 63The heading's level, which fits the page's outline
hrefstringLinks the card through its heading, stretched over the whole card

The Card doesn't fire events of its own. Its link and its actions fire theirs, like click, which you listen to on them.

SlotDescription
defaultThe body, like a sentence or two and some details
headingThe heading, when it's more than plain text, or your router's link, which is stretched too
eyebrowA short line shown above the heading, like a category, which comes after it in the DOM
mediaAn image, shown at the top, which comes after the heading in the DOM
actionsButtons that act on the card, like Save, which sit above the stretched link
footerA last line of details about the card, like who made it or when

We also export CardProps, which comes in handy for wrappers and data-driven pages:

import type { CardProps } from "@/components/Card";

const featured: CardProps = { heading: "Analytical Engine", headingLevel: 2, href: "/projects/analytical-engine" };

Composable

The root element is the card itself, the <article> or whichever element as picks, so everything you pass lands directly on it. That includes classes, styles, id, dir, lang, ARIA attributes and event listeners. A ref to the Card reaches that same element, so there's no wrapper to reach through.

If you'd like an <article> to have a name of its own, give the heading's text an id, through the heading slot, and point the card's aria-labelledby at it. Keep in mind that a <section> with a name becomes a region landmark, which people can jump to, so only name a section card when it's one of the main parts of the page.

href renders a plain <a>. For links that go through your router, put your router's link in the heading slot instead, and the Card stretches it over the card just the same, since it stretches any link in its heading. Routers that handle every link on the page only need the href:

import { Link } from "react-router";

<Card heading={<Link to="/projects/analytical-engine">Analytical Engine</Link>}>
  <p>A general-purpose mechanical computer.</p>
</Card>

Since the Card doesn't know anything about your router, it works with any of them.

With other components

  • Actions are Buttons, which keep their names, states and focus rings, and sit above the stretched link. We recommend sm Buttons, ranked outline or ghost, so a page of cards doesn't fill up with primary actions.
  • A Spinner doesn't belong in a card while it loads. A Skeleton, on our Roadmap, will hold the card's shape instead.
  • A Badge, also on our Roadmap, fits in the eyebrow, to mark a status like "New".
  • A card that opens a Dialog rather than a page gets a Button for it in actions, rather than a stretched link, since a link should always go somewhere.

Controllable

The Card doesn't keep any state of its own. Everything about it is a prop, an attribute or an element that you control:

WhatHow you control it
Elementas
Heading levelheadingLevel
Linkhref, or a link in the heading slot
Hover, focusThe browser, from the link
FocusThe link's own focus(), through a ref to the card or to your link

DX Checklist

RuleLevelScopeRequirementBasisCheck
card/shared-vocabularyMustComponentIt takes href like the Button and actions like the Input, and names its other slots after its parts: heading, eyebrow, media and footerOpen ComponentsReview
card/no-attribute-namesShouldComponentNo prop shares its name with an HTML attribute that does something else, like titleOpen ComponentsReview
card/typedMustComponentProps and slots are typed, with unions rather than string or number, so headingLevel only takes 2 to 6Open ComponentsType check
card/root-elementMustComponentThe root element is the card itself, so attributes, listeners and refs land on itOpen ComponentsUnit test
card/router-linksShouldComponentA link in the heading slot, like a router's, is stretched over the card like the one href rendersOpen ComponentsUnit test, review
card/statelessShouldComponentIt keeps no state of its ownOpen ComponentsReview

FAQ

Copyright © 2026