Patterns
Reusable blocks that work on any markup. Each has a small custom-property API, and some have a script.
Patterns are BEM blocks written as Tailwind @utility, so they take variants
(md:btn--small) and can be @apply'd. Their knobs follow
the custom-property API. Each header comment below is the file's own: it's the API.
css/patterns/ · js/
Layout
Layout grid
A page grid with named tracks. Children sit in the content track unless they ask for another. Outside the grid, the same classes cap the width and centre the element. Why: layout.
Markup
<div class="layout-grid gap-y-8">
<div class="layout-full demo-box">layout-full</div>
<div class="layout-wide demo-box">layout-wide</div>
<div class="demo-box">content (default)</div>
<div class="layout-narrow demo-box">layout-narrow</div>
<div class="layout-start demo-box">layout-start</div>
<div class="layout-end demo-box">layout-end</div>
</div>
Markup
<div class="stack-8">
<div class="layout-wide demo-box">layout-wide, not in a grid</div>
<div class="layout-width-xs demo-box">layout-width-xs</div>
</div>
Layout
layout-grid: a page-level grid with named tracks. Children sit in `content`
unless they ask for another track.
| full | wide | content | wide | full |
| start | end |
layout-full | layout-wide | layout-content | layout-narrow:
inside layout-grid they pick a track; anywhere else they cap the width and
centre the element, keeping --gutter clear of the viewport edge.
layout-start | layout-end: the two halves of the content track, side by
side from md up. Only valid inside layout-grid.
layout-grid--ruled: Kite Co.'s keyline down the inline-start edge of the
wide track, through the grid's padding, from md up. --layout--inset keeps
content clear of it.
layout-width-{size}: a gutter-aware max width for any --container-* token.
Role widths are set below; point them at different containers per project.
--layout--inset is the least space between the wide and content edges.
Kite Co. runs content the full width of the frame, inset from the keyline,
and lets each element cap its own measure, so text sits to the left.
Section
Vertical padding for a page band. Combine it with a surface and the layout grid.
section surface-lilac layout-grid
Markup
<div class="section surface-lilac layout-grid">
<p class="demo-box">section surface-lilac layout-grid</p>
</div>
Section
Vertical padding for a page band. Combine with a surface and layout-grid:
<section class="section surface-blue layout-grid">…</section>
For Kite Co.'s ruled bands, add rule-block-start and layout-grid--ruled.
Block knob: --section--padding-y.
Grids
- grid-simple
- cols-2
- md:cols-4
- 4
- grid-auto
- fits
- as many
- as it can
Markup
<div class="stack-16">
<ul class="grid-simple cols-2 md:cols-4 list-reset">
<li class="demo-box">grid-simple</li>
<li class="demo-box">cols-2</li>
<li class="demo-box">md:cols-4</li>
<li class="demo-box">4</li>
</ul>
<ul class="grid-auto list-reset">
<li class="demo-box">grid-auto</li>
<li class="demo-box">fits</li>
<li class="demo-box">as many</li>
<li class="demo-box">as it can</li>
</ul>
</div>
Grids
grid-simple: --cols equal columns. Set the count with cols-{n}, which works
with variants: <ul class="grid-simple cols-1 md:cols-2 lg:cols-4">
grid-auto: as many columns as fit, each at least --grid-auto--min-inline-size.
Both read the global --gap, --col-gap and --row-gap. --cols is registered as
non-inheriting (tokens/globals.css), so nested grids start from one column.
Flex grid
- flex-grid
- cols-3
- centres
- the last
- row
Markup
<ul class="flex-grid cols-3 justify-center list-reset">
<li class="demo-box">flex-grid</li>
<li class="demo-box">cols-3</li>
<li class="demo-box">centres</li>
<li class="demo-box">the last</li>
<li class="demo-box">row</li>
</ul>
Flex grid
Like grid-simple, but the last row's items can be centred or justified,
because it's flexbox. Children are sized to --cols columns (set with cols-{n}).
flex-grid-auto: children keep their own width and wrap.
Flex list
- Websites
- CMS & Content Systems
- Strategy
Markup
<ul class="flex-list">
<li class="tag">Websites</li>
<li class="tag">CMS & Content Systems</li>
<li class="tag">Strategy</li>
</ul>
Flex list
A wrapping row of items at their natural width: tags, inline nav, buttons.
Only a <ul>/<ol> has its list styles reset.
Text
Type styles
Family and weight composed with a size token. Headings get theirs by default.
type-label: a surtitle
type-h3 on a paragraph
Markup
<div class="stack-12">
<p class="type-label">type-label: a surtitle</p>
<p class="type-h3">type-h3 on a paragraph</p>
<p class="type-meta">type-meta: captions, credits and dates</p>
</div>
Type styles
Sizes are theme tokens (text-h1 …, see tokens/theme.css), each carrying its
line height and letter spacing. These compose family and weight with a size.
Headings get them in base/elements.css.
type-h1 … type-h6, type-hero IBM Plex Serif Medium
type-base IBM Plex Sans, the body default
type-label, type-label-sm IBM Plex Mono SemiBold: surtitles, nav, tags
type-meta IBM Plex Serif Medium, small: captions, credits, dates
type-code IBM Plex Mono, small: code blocks (pre gets it by default)
Flow
Space between siblings, declared by each element: a paragraph after a heading sits closer than a heading after a paragraph. For one fixed gap, use stack. Why: flow and rhythm.
A heading
Each child sets its own space above.
Paragraphs sit a base step apart.
Markup
<div class="flow">
<h4>A heading</h4>
<p>Each child sets its own space above.</p>
<p>Paragraphs sit a base step apart.</p>
<figure class="bg-lilac p-8 rounded-sm">A figure falls back to layout space.</figure>
</div>
Flow
Space between siblings. Each child declares the space above itself with
--flow-space (registered as non-inheriting in tokens/globals.css); anything
that doesn't falls back to --space-layout.
Element defaults (p, headings, lists) are in base/elements.css. For one
fixed gap regardless of element, use stack-{n} instead.
Prose
Flow plus a reading measure and list styling, for rich text you don't control.
Rich text from a CMS
Lists get their markers back inside prose.
- And spacing between items
-
Nested lists
- indent further
- Ordered lists use numbers
- Like this
- not-prose
- leaves
- this list
- alone
Markup
<div class="prose">
<h3>Rich text from a CMS</h3>
<p>Lists get their markers back inside prose.</p>
<ul>
<li>And spacing between items</li>
<li>
Nested lists
<ul>
<li>indent further</li>
</ul>
</li>
</ul>
<ol>
<li>Ordered lists use numbers</li>
<li>Like this</li>
</ol>
<div class="not-prose">
<ul class="flex-list">
<li>not-prose</li>
<li>leaves</li>
<li>this list</li>
<li>alone</li>
</ul>
</div>
</div>
Prose
Flow plus a reading measure and list styling, for rich text you don't
control (CMS content, Markdown).
Add the class not-prose to opt a subtree out of the list styling. It needs no
CSS of its own: prose's selectors exclude it with :where(). It's an exclusion,
not a reset, so a .prose nested inside .not-prose stays excluded.
Link
Markup
<p class="flex-list [--gap:--spacing(24)]">
<a href="#link">The default link</a>
<a class="link--subtle" href="#link">link--subtle</a>
<a class="link--foreground" href="#link">link--foreground</a>
</p>
Link
Applied to every <a> in base/elements.css.
Context knobs (never declared, so a surface or parent can set them):
--link--color falls back to --color-accent
--link--color--hover falls back to --link--color mixed toward the foreground
Block knobs (declared here, set by variants):
--link--text-decoration-line, --link--text-decoration-thickness (+ --hover)
The underline thickens on hover, so there's feedback even where the accent
and the foreground are the same colour (Kite Co.'s blue on neutral).
Blockquote
Kite Co. brought both deep technical expertise and genuine care to every stage.
The client
Markup
<blockquote>
<p>Kite Co. brought both deep technical expertise and genuine care to every stage.</p>
<cite>The client</cite>
</blockquote>
Blockquote
Applied to <blockquote> in base/elements.css. Kite Co.'s pixel quote mark
above the quote, the quote set in the h1 style, the <cite> as body text.
<blockquote><p>Quote</p><cite>Name, role</cite></blockquote>
Block knobs: --blockquote--gap, --blockquote__mark--block-size,
--blockquote__mark--background-color.
Controls
Tag
- Websites
- A linked tag
Markup
<ul class="flex-list">
<li class="tag">Websites</li>
<li><a class="tag" href="#tag">A linked tag</a></li>
</ul>
Tag
A small outlined label in Plex Mono, for services, categories and filters:
<ul class="flex-list"><li class="tag">Websites</li></ul>
<a class="tag" href="…">Websites</a>
Drawn in currentColor, so it follows the text colour on every surface.
A linked tag fills faintly on hover.
Block knobs: --tag--padding-x, --tag--padding-y, --tag--border-width,
--tag--border-radius, --tag--background-color (+ --hover).
Form controls
Classes for form markup you control. The opt-in form defaults apply them to bare elements.
Markup
<div class="max-w-(--container-2xs) stack-16">
<label class="stack-8 block">
<span class="type-label-sm">input</span>
<input class="input" type="email" placeholder="ada@example.com" />
</label>
<label class="stack-8 block">
<span class="type-label-sm">select</span>
<select class="select">
<option>One</option>
<option>Two</option>
</select>
</label>
<div class="input-group">
<label class="sr-only" for="search">Search</label>
<input id="search" type="search" placeholder="input-group" />
<button class="btn btn--small" type="button">Search</button>
</div>
<label class="flex items-center gap-8"><input class="checkbox" type="checkbox" checked /> checkbox</label>
<label class="flex items-center gap-8"><input class="radio" type="radio" name="radio-demo" checked /> radio</label>
</div>
Input
Text inputs and textareas. select builds on it (select.css). base/forms.css
applies it to bare elements when that file is turned on.
Block knobs: --input--padding-x, --input--padding-y, --input--border-width,
--input--border-color, --input--border-radius, --input--background-color
(+ --hover, --focus), --input--color.
Focus uses the global :focus-visible ring.
Select
An input with a chevron. The chevron is two currentColor gradients, so it
follows the text colour in every context. (A <select> can't have a pseudo-
element, which rules out a mask icon.)
Block knobs: --select--icon-size, plus everything from input.
Checkbox
Restyles the input itself (appearance: none), so no wrapper is needed:
<label><input type="checkbox" class="checkbox"> Remember me</label>
Block knobs: --checkbox--size, --checkbox--border-width, --checkbox--border-color,
--checkbox--border-radius, --checkbox--background-color (+ --checked),
--checkbox--color--checked (the tick).
Radio
Restyles the input itself (appearance: none):
<label><input type="radio" name="size" class="radio"> Small</label>
Block knobs: --radio--size, --radio--border-width, --radio--border-color,
--radio--background-color, --radio--color--checked (the dot).
Focus ring
On :focus-visible only, in the context's foreground. Tab through this
example to see it on each surface.
Markup
<p class="flex-list">
<a class="btn" href="#focus">On the page</a>
<span class="surface-blue p-16 rounded-md"><a class="btn" href="#focus">On blue</a></span>
</p>
Focus ring
Applied to :focus-visible in base/reset.css. Reads the global knobs:
--focus-color not declared; falls back to the context's foreground
--focus-width, --focus-offset declared in tokens/globals.css
Media
Media embed
Markup
<figure class="media-embed max-w-(--container-2xs)">
<iframe
title="An embedded page"
srcdoc="<p style='font: 1rem system-ui; padding: 1rem'>An iframe or video, held at 16 / 9</p>"
></iframe>
<figcaption>A figcaption under the embed</figcaption>
</figure>
Media embed
A figure holding an iframe or video at a fixed aspect ratio, with a caption.
<figure class="media-embed"><iframe …></iframe><figcaption>…</figcaption></figure>
Block knob: --media-embed--aspect-ratio (16 / 9).
Icons
Both draw in currentColor, so icons follow the text on every surface.
Markup
<p class="flex items-center gap-24 type-h3">
<span class="mask-icon [mask-image:url('data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cpath%20d%3D%22M2%207h9.6L7.3%202.7l1.4-1.4L15.4%208l-6.7%206.7-1.4-1.4L11.6%209H2z%22/%3E%3C/svg%3E')]"></span>
<span class="cross block size-24" aria-hidden="true"></span>
</p>
Mask icon
Paints a mask image in currentColor, so icons follow the text colour.
.thing::after { @apply mask-icon; mask-image: url("/icons/arrow.svg"); }
Block knob: --mask-icon--size (1em).
Cross
Two bars drawn with pseudo-elements, in currentColor. Rotate or scale them
for a burger-to-close transition.
Read with fallbacks: --cross--size (arm length, 100%), --cross--stroke-width (2px).
Interaction
Patterns with a script. Each hangs off data-* hooks, is set up with
define() so it works on markup added later,
and has a working state without JS. Why:
progressive enhancement.
Disclosure
For a simple accordion, prefer native <details name>: it needs no
script. Use this when the trigger and target can't be a summary and details.
Hidden with hidden and inert. Press Escape inside to
close it. Focusable link example.
Markup
<div class="stack-8">
<button class="btn btn--secondary" type="button" aria-controls="answer-1" aria-expanded="false">
<span data-show-collapsed>Show the answer</span>
<span data-show-expanded>Hide the answer</span>
</button>
<div id="answer-1" data-disclosure data-disclosure-animate hidden>
<p class="surface-white p-16 rounded-md">
Hidden with <code>hidden</code> and <code>inert</code>. Press Escape inside to
close it. <a href="#disclosure">Focusable link example</a>.
</p>
</div>
</div>
First question
Only one in the group stays open.
Second question
Opening this closes the first.
Markup
<div class="stack-8">
<details name="faq">
<summary>First question</summary>
<p>Only one in the group stays open.</p>
</details>
<details name="faq">
<summary>Second question</summary>
<p>Opening this closes the first.</p>
</details>
</div>
Disclosure: show and hide any element from one or more triggers.
https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/
<button aria-controls="faq-1" aria-expanded="false">Question</button>
<div id="faq-1" data-disclosure hidden>Answer</div>
The target is hidden with `hidden` and made `inert` while collapsed, so its
contents leave the tab order and the accessibility tree without touching
tabindex. Triggers get aria-expanded; a non-button trigger also gets
role="button", a tab stop and Enter/Space.
For a simple accordion, prefer native <details name="group">: it needs no JS.
Use this when the trigger and target can't be <summary> and <details>, or you
need escape-to-close, focus-out, hash or grouping behaviour.
Data attributes on the target (all optional):
data-disclosure-animate animate height (skipped for reduced motion)
data-disclosure-group="name" expanding one collapses the others in the group
data-disclosure-focusout collapse when focus or a click leaves it
data-disclosure-focus-within move focus into the target on expand
data-disclosure-expand-on-hash="false" don't expand when the URL hash is its id
Show or hide parts of a trigger by state with data-show-expanded and friends
(css/utilities/aria-expanded.css).
Events, dispatched on the target and bubbling: expandbegin, expandend,
collapsebegin, collapseend.
Show or hide parts of a disclosure trigger based on its aria-expanded state:
<button aria-controls="menu" aria-expanded="false">
<span data-show-collapsed>Open menu</span>
<span data-show-expanded>Close menu</span>
</button>
data-show-expanded / data-hide-collapsed: visible only when expanded.
data-hide-expanded / data-show-collapsed: visible only when collapsed.
Dialog
This content came from a template, cloned into the slot when the dialog opened.
This opener asked for a different template with data-dialog-template.
Markup
<p class="flex-list">
<button class="btn" type="button" commandfor="demo-dialog" command="show-modal">
Open with commandfor
</button>
<button
class="btn btn--secondary"
type="button"
data-dialog-open="demo-dialog"
data-dialog-template="dialog-content-alt"
>
Open with data-dialog-open
</button>
</p>
<dialog
id="demo-dialog"
class="surface-neutral m-auto max-w-[min(--spacing(560),100%-2*var(--gutter))] rounded-md p-24 backdrop:bg-blue/60"
aria-labelledby="demo-dialog-title"
data-dialog
data-dialog-template="dialog-content"
>
<div class="stack-16">
<h2 id="demo-dialog-title" class="type-h3">A dialog</h2>
<div data-dialog-slot></div>
<button class="btn btn--secondary" type="button" commandfor="demo-dialog" command="close">
Close
</button>
</div>
</dialog>
<template id="dialog-content">
<p>This content came from a template, cloned into the slot when the dialog opened.</p>
</template>
<template id="dialog-content-alt">
<p>This opener asked for a different template with <code>data-dialog-template</code>.</p>
</template>
Dialog: extras on top of native <dialog>.
<button commandfor="signup" command="show-modal">Sign up</button>
<button data-dialog-open="signup" data-dialog-template="signup-form">Sign up</button>
<dialog id="signup" data-dialog aria-labelledby="signup-title">
<h2 id="signup-title">Sign up</h2>
<div data-dialog-slot></div>
<button commandfor="signup" command="close">Close</button>
</dialog>
<template id="signup-form">…</template>
Opening:
- Invoker commands (commandfor + command="show-modal" | "close" | "request-close")
work natively where supported; this adds them where they aren't.
- data-dialog-open="id" and data-dialog-close="id" work anywhere on the page,
and a bare data-dialog-close works inside the dialog.
Content: an opener (or the dialog) with data-dialog-template="template-id" has
that <template> cloned into [data-dialog-slot] on open. The slot is emptied once
the dialog has closed and finished animating, which also stops embedded media.
Other data attributes on the dialog:
data-dialog-modal="false" open with show() instead of showModal()
data-dialog-backdrop-close="false" don't close on a backdrop click
Scroll locking is CSS (css/utilities/scroll-lock.css). Native <dialog> handles
focus trapping, Escape and returning focus to the opener.
Animate on scroll
Items play when their trigger scrolls into view, and replay when you scroll back up past it. Why data attributes: animate on scroll hooks.
Fades in
one after
another
Markup
<div class="grid-simple cols-1 md:cols-3" data-animate data-animate-stagger="120">
<p class="demo-box" data-animate-item>Fades in</p>
<p class="demo-box" data-animate-item>one after</p>
<p class="demo-box" data-animate-item>another</p>
</div>
No trigger, so it plays itself
Played by the box opposite, sliding up
Markup
<div class="grid-simple cols-1 md:cols-2">
<p id="animate-trigger" class="demo-box" data-animate-item>No trigger, so it plays itself</p>
<p class="demo-box [--animate--animation-name:fade-in-translate]" data-animate-item="animate-trigger">
Played by the box opposite, sliding up
</p>
</div>
Outer trigger's item
Inner
trigger's
items
Markup
<div class="stack-16" data-animate>
<p class="demo-box" data-animate-item>Outer trigger's item</p>
<div class="grid-simple cols-3" data-animate data-animate-stagger="60">
<p class="demo-box" data-animate-item>Inner</p>
<p class="demo-box" data-animate-item>trigger's</p>
<p class="demo-box" data-animate-item>items</p>
</div>
</div>
Animate on scroll
<div data-animate data-animate-stagger="80">
<p data-animate-item>…</p>
<p data-animate-item>…</p>
</div>
<h2 data-animate-item>…</h2> no trigger: plays itself
<section id="intro" data-animate>…</section>
<img data-animate-item="intro" …> played by #intro, wherever it is
js/animate.ts sets [data-playing] on each item when its trigger scrolls into
view; this file does the animating. An item's trigger is the id in its value,
else its nearest [data-animate] ancestor, else the item itself, so nested
triggers each play only their own items.
Data attributes rather than classes: the hidden state has to match exactly
what the script will reveal (see docs/decisions/009-animate-hooks.md).
Items stay visible without JS (the html.js class), when the user prefers
reduced motion, and in print.
Knobs (never declared, read with a fallback). Set them on the item or any
ancestor, the trigger included; a remote item doesn't inherit from its trigger.
--animate--animation-name fade-in, or fade-in-translate
--animate--animation-duration 300ms
--animate--animation-timing-function ease-out
--animate--animation-delay 50ms, added to the item's place in the queue
--animate--translate fade-in-translate's start offset, 0 16px
Set by the script: --animate__item--animation-delay, the item's place in the queue.
Animate on scroll. Sets [data-playing] on [data-animate-item] elements when
their trigger scrolls into view; css/patterns/animate.css does the rest.
<div data-animate data-animate-stagger="80">
<p data-animate-item>…</p>
</div>
<h2 data-animate-item>…</h2> no trigger: plays itself
<section id="intro" data-animate>…</section>
<img data-animate-item="intro" …> played by #intro, wherever it is
An item's trigger is the element its value names by id, else its nearest
[data-animate] ancestor (not itself), else the item itself. Each item has
exactly one trigger, so nested triggers play only their own items, and an
element can be an item of an outer trigger and a trigger for its own
(data-animate data-animate-item). A named trigger must be in the DOM when
the item is set up; if it isn't, the item plays itself.
Items that start together play one after another in document order, across
triggers: each waits the stagger of the one before (its trigger's
data-animate-stagger, default 100ms), capped at 600ms from now so a fast
scroll never queues a backlog. The wait goes in --animate__item--animation-delay.
Items replay when their trigger goes back below the viewport (the user
scrolled up past it), not when it scrolls off the top. A trigger already
above the viewport (an anchor link, a restored scroll) plays straight away,
off screen, without holding up the queue.
Needs the no-js → js class swap in <head> so items start hidden only when
this script will run.