Developer Experience (DX)
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:
| Name | Kind | What it means |
|---|---|---|
href | Prop | The component is a link, and this is where it goes |
default | Slot | The main content, between the component's tags |
actions | Slot | Buttons 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:
headingis the heading, andheadingLevelits level. We didn't call ittitle, 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.labelis for controls, which a card isn't.aspicks the element, as it does in many component libraries. We recommendarticleas the default, since it's what most cards are.media,eyebrowandfooterare named after the parts they fill, so the slot and thedata-slotit renders have the same name.
The Card also leaves a few props out, on purpose:
- There's no
variantorcolor. 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 thehrefand 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.
| Prop | Type | Default | Description |
|---|---|---|---|
as | "article" | "section" | "div" | "li" | "article" | The element it renders |
heading | string | The heading, when it's plain text. The heading slot takes precedence | |
headingLevel | 2 | 3 | 4 | 5 | 6 | 3 | The heading's level, which fits the page's outline |
href | string | Links 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.
| Slot | Description |
|---|---|
default | The body, like a sentence or two and some details |
heading | The heading, when it's more than plain text, or your router's link, which is stretched too |
eyebrow | A short line shown above the heading, like a category, which comes after it in the DOM |
media | An image, shown at the top, which comes after the heading in the DOM |
actions | Buttons that act on the card, like Save, which sit above the stretched link |
footer | A 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.
Links and routers
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
smButtons, rankedoutlineorghost, 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:
| What | How you control it |
|---|---|
| Element | as |
| Heading level | headingLevel |
| Link | href, or a link in the heading slot |
| Hover, focus | The browser, from the link |
| Focus | The link's own focus(), through a ref to the card or to your link |
DX Checklist
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
card/shared-vocabulary | Must | Component | It takes href like the Button and actions like the Input, and names its other slots after its parts: heading, eyebrow, media and footer | Open Components | Review |
card/no-attribute-names | Should | Component | No prop shares its name with an HTML attribute that does something else, like title | Open Components | Review |
card/typed | Must | Component | Props and slots are typed, with unions rather than string or number, so headingLevel only takes 2 to 6 | Open Components | Type check |
card/root-element | Must | Component | The root element is the card itself, so attributes, listeners and refs land on it | Open Components | Unit test |
card/router-links | Should | Component | A link in the heading slot, like a router's, is stretched over the card like the one href renders | Open Components | Unit test, review |
card/stateless | Should | Component | It keeps no state of its own | Open Components | Review |
FAQ
<article>, a real heading and a real link, which work before the page hydrates, and even without JavaScript at all. Only your actions, like Save, wait for your script.