The component library

What ships with the engine, what a site keeps to itself, and how to tell which is which before writing either.

The test

A component belongs in the engine when it is a distinct kind of content that more than one site will have. It belongs in a site when it exists to render that site's subject matter.

Three questions settle almost every case:

  1. Is it a different kind of content, or the same content shown differently? A grid of links and a list of properties are different content. That grid in two columns and the same grid in three is one component with a field. A catalogue that models presentation as content ends up with fifteen near-identical blocks nobody can choose between.
  2. Would a second site have one? A logo strip saying who a product works with is general. A departure board mockup is not.
  3. Does it describe the subject, or the page? A transit stack diagram describes transit. A screenshot figure describes any page that has a picture.

The same rule already governs site.css, and it holds for the same reason: a component appearing on a second site belongs in the shared layer.

What the evidence says

gtfs.media is the test case, and the numbers are unusually clear. Its site.css defines 74 classes. After migrating its content to the engine's components, none of them were still used, because everything they styled had either found a shared component or fallen back to prose.

Grouping those 74 by what they were for gives the classification:

What the evidence says
Group Classes Where it belongs Why
`api-*` 24 A **generator** The API reference is built from `openapi.yaml`. It is data rendered as a page, not a page someone writes
`board-*`, `route-pill`, `r-*`, `live-dot` 12 **Site** A departure board mockup illustrating this product. No second site has one
`gloss-*` 4 **Engine**, as `defs` A term-and-definition list is general. The glossary's 53 terms are data, so it is also a generator case
`works-with`, `logo-row`, `wm-*`, `org` 5 **Engine**, as `logos` Appeared identically on six pages. "Who this works with" is a pattern every product site has
`module-*` 4 **Engine**, as `features` Copy beside media, which the catalogue now covers
`stack-*`, `pylon-stack`, `eye-*` 8 **Site** Diagrams and a brand animation, specific to this subject
The rest 17 **Neither** Duplicates of shared classes, or code-block token classes that belong in the stylesheet

Two lessons hold beyond this site. Most of what looks site-specific is a generator case: content that is really data. And the largest genuinely site-specific groups are illustrations of the product itself, which is the one thing a shared library can never anticipate.

The default catalogue

Eighteen components ship today. Each has a schema carrying its fields, its limits, the accessibility rules attached to it, and a when_not line distinguishing it from its nearest neighbour. docs/components/ is generated from those schemas, so the reference cannot describe a rule the build does not enforce.

The default catalogue
Component For Distinguished from
`prose` Running text Everything. It is the fallback and always a correct answer. Its `body` is optional: a heading with no body introduces the blocks that follow
`hero` The opening block, carrying the page `h1` A `cta`, which closes rather than opens
`cards` A grid of links routing to other pages `ticks`, whose items do not link
`steps` A sequence where order carries meaning `ticks`, which is unordered
`ticks` A marked list of properties or claims `cards`, whose items link somewhere
`split` Two balanced choices, such as two audiences `cards`, which is three or more
`features` Copy beside media, repeated down a page `split`, which is two panels of copy
`shots` Screenshots or photographs with captions, at one of three widths `features`, where the picture supports adjacent copy
`code` A sample, a request, a file listing Inline code in a sentence
`table` Tabular data with a caption and headers `defs`, which is term and definition
`disclosure` Detail some readers want, collapsed Prose, for anything a reader needs in order to act
`callout` A note, caveat, or warning set apart Emphasis. If most of a page is callouts, none are read
`quote` A quotation with attribution Emphasis on your own words. If nobody said it, it is not a quote
`defs` Terms and their definitions `table`, when rows share meaningful columns
`logos` Marks for what a product works with Decoration, or a customer list posing as endorsement
`plain` The plain-language version of a page A summary. It is written for its own readers
`proof` A dark strip carrying one claim `cta`, which asks for something
`cta` The closing ask `hero`, which opens

What other systems ship

A survey of 17 design systems, CMS block libraries, and docs generators is in dev/research/COMPONENT-RESEARCH.md, with 120 sources. Stripping the seven items that are site chrome here rather than author-placeable blocks, the cross-system frequency runs:

What other systems ship
Rank Component In Here
1 Button 12/17 A field on other blocks, not a block
3 Rich text 10/17 `prose`
4 Image 9/17 `shots`
5 **Callout / admonition** 9/17 **Missing**
6 Tabs 9/17 Deliberately absent, below
8 Card 9/17 `cards`
9 Layout container 8/17 Deliberately absent, below
10 Table 7/17 `table`
11 Icon 7/17 Partial: a field, currently unimplemented
13 Accordion 7/17 `disclosure`
14 Details / disclosure 7/17 `disclosure`
17 Form embed 7/17 Not applicable; these sites use mailto
19 Media and text 6/17 `features`
23 Hero 6/17 `hero`
24 Call to action 6/17 `cta`
25 **Blockquote** 5/17 **Missing**

Two genuine gaps, callout and quote, and two deliberate absences.

steps, plain, proof, split and ticks do not rank because they are either domain-specific to this design system or, in the case of steps and code, near-universal among docs systems and absent everywhere else. Both are kept on those grounds.

The variant axis, and why the catalogue stays small

The survey's strongest finding confirms the first question above from the outside: thirteen tempting blocks reduce to eight real ones. Testimonial is quote with a variant. Logo cloud is a gallery variant. Newsletter signup is a form embed. Warning, danger, and success are one callout with a severity field. Row, Stack, and Grid are one container.

Systems without a variant axis pay for it in content types that cannot be changed later:

  • Contentful's own marketing starter encodes layout as content: heroSize: Boolean, imageStyle: Boolean, containerLayout: Boolean.
  • Sanity's clean template contradicts Sanity's own doctrine with a field group named designSystem.
  • Varbase ships bp_columns, bp_columns_two_uneven and bp_columns_three_uneven as three separate content types. Thunder ships instagram, twitter and pinterest as three.

The payoff for getting it right is concrete. WordPress models roughly 33 embed providers as variations of one core/embed block, so deprecating Facebook and Instagram and renaming Twitter to X required no content migration at all. Only Prismic, Drupal, and partly WordPress have a real variant axis.

This is why columns on cards is a field and not three components, and why the callout being added below takes a severity enum rather than arriving as three blocks.

What is missing, and why it is on the list

The migration named these by falling back to prose, ranked by how often:

Four of the five gaps the survey and the migration named between them are now built: callout (9/17, the largest measured gap), quote (5/17), defs (53 terms on one page), and logos (6 pages, identical markup). faq was renamed to disclosure, for the reason given below.

What is missing, and why it is on the list
Candidate Status
`embed` Specified but unbuilt. Will require a title, a fallback link, and a declared aspect ratio
`icon` 7/17. A field on `cards` in the original schema, removed when it had no icon set behind it. Worth restoring with one
`form` 7/17. Not applicable while these sites contact by mailto. When one is needed, 3.3.5 Help and 3.3.6 Error Prevention arrive with it
`video` Deliberately unbuilt. See below

What will not be added

No video component until its cost is accepted. WCAG 1.2.6 through 1.2.9 ask for sign language interpretation, extended audio description, and a full text alternative. When a video component exists, captions, a transcript, and an audio description will be required schema fields, so a page cannot ship a video that fails those criteria. Until a site needs one, the absence is the cheapest way to hold the line.

No carousel. 4 of 17 systems ship one, and every system that does documents that it underperforms. Runyon's Notre Dame study found a 1.07% click-through rate over 3.76 million visits, with 89.1% of all clicks landing on slide one. GDS removed theirs as the most negatively reviewed element they had. Even ECL, which still ships one, warns of "a high risk that users miss content" and recommends cards instead. Worth stating honestly: no design system publishes an outright ban, and Runyon's best-performing carousel was the auto-advancing one, so the usual anti-autoplay argument is weaker than it is normally made. The case against rests on the click distribution, not on the animation. A card grid does the same job with no WCAG 2.2.2 exposure.

No tabs, and this is the interesting one. Tabs rank 6th at 9 of 17, which makes them the most common thing on this list to refuse. Frequency is not evidence of correctness:

  • GOV.UK ships tabs while its own guidance states "Tabs hide content from users" and admits "This component has not yet been tried in research with users."
  • USWDS proposed tabs in 2022 and still has not shipped them, on the grounds that hiding content "has a cost for all users, particularly those with physical or cognitive disabilities."
  • Docusaurus and Starlight, the two most deployed docs frameworks, both omit the tab-to-panel association the ARIA Authoring Practices Guide requires. Neither emits aria-controls on its tabs, and neither emits aria-labelledby on its panels. Docusaurus additionally has no arrow-key handling at all; Starlight implements roving focus with arrow keys, Home and End, and still leaves the association out. Both read from source on main.

That is the argument, and it is worth stating precisely. The pattern is not impossible: each framework got part of it right. But two independent teams building the same component for the same audience both shipped it incomplete, in the same place, and neither documents which parts it implements. Starlight's tabs documentation page contains no mention of ARIA, keyboard, or assistive technology at all.

A component whose correctness cannot be established without reading its source is a component a content library should not offer. It is the same standard this project holds itself to: a thing is not finished when it works, it is finished when someone can tell that it works.

No modal. Not as an author-placeable block. Focus management, return focus, and escape handling are all easy to get wrong and impossible to check from content.

No separate FAQ block. 3 of 17, and its entire justification was FAQPage rich results, which Google restricted to authoritative government and health sites and then removed from Search entirely in June 2025. An FAQ is a disclosure list of question and answer pairs, which is what disclosure is. Confirmed against Google's own documentation, not the report.

No layout components. No columns, no spacer, no container. The survey's advice is to collapse Row, Stack and Grid into one container; this system goes further and has none, because the renderer owns layout entirely and a container block would be presentation modelled as content. That is a real disagreement with the surveyed systems, and it is only available because the component set is closed.

Adding one

A component earns its place by failing the three questions above in the right direction. When it does:

components/<name>/schema.yml      fields, limits, when_not, accessibility
components/<name>/template.njk    the markup
components/<name>/props.js        optional, for anything computed

Put it in src/components/ first, on the site that needs it. If a second site needs it, move it into the engine and the shared stylesheet together. That order matters: a component promoted before a second site has asked for it is a guess.

Figure widths, and a word that meant two things

shots takes a width of column, wide, or bleed.

column is the default and fills the content container, which is what "full width" usually means. It only needed naming because the component had a bug: a single figure was being wrapped in a two-column grid, so one image rendered at half the width it should have. A lone figure now gets the container.

wide overflows the container. bleed runs to the viewport edges, and keeps its caption inside the text column, because a caption is prose and prose has a measure.

The word full was the first name for bleed and it was wrong: it read as "fills its container" to one person and "runs to the edges" to another, in the same conversation. bleed is the print term and carries only the second meaning.

This field is not only layout. Each value declares its own sizes expression, so a bleeding image generates viewport-width variants and a column one does not. The schema mechanism is sizes_by, which points a media slot at a sibling field. Without it, choosing a width would silently ship the wrong image widths.

Where the styles live

Three layers, and the middle one is temporary:

Where the styles live
Layer Holds Example
`base.css` on the CDN The design system Sections, cards, buttons, the dark-band remap
`pending-v3.css`, shipped by the engine Rules the components need that base.css v2 lacks Disclosures, tables, callouts, quotes, definition lists, logo rows
A site's `site.css` What that site alone has gtfs.media's departure board

The middle layer exists because the class allowlist refused to let those rules ship, one at a time, as each component was built. Every rule in it is a v3 candidate. When v3 lands the file empties and sites stop linking it.

The result is measurable. gtfs.media's site.css went from 610 lines defining 74 classes to 92 lines defining 14, and the 14 are the departure board and its route pills. The fixture site's is seven lines of comment saying it needs nothing, which is the number a site should aim at.

Next

Continue with markup.