Skip to content

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.

layout-full
layout-wide
content (default)
layout-narrow
layout-start
layout-end
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>
layout-wide, not in a grid
layout-width-xs
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>
css/patterns/layout.css
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>
css/patterns/section.css
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>
css/patterns/grid.css
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>
css/patterns/flex-grid.css
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 &amp; Content Systems</li>
    <li class="tag">Strategy</li>
</ul>
css/patterns/flex-list.css
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

type-meta: captions, credits and dates

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>
css/patterns/type.css
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.

A figure falls back to layout space.
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>
css/patterns/flow.css
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
  1. Ordered lists use numbers
  2. 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>
css/patterns/prose.css
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.

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>
css/patterns/blockquote.css
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

Button

Button Secondary

Markup
<p class="flex-list">
    <a class="btn" href="#button">Button</a>
    <a class="btn btn--secondary" href="#button">Secondary</a>
    <button class="btn btn--ghost" type="button">Ghost</button>
    <button class="btn btn--small" type="button">Small</button>
    <button class="btn btn--square" type="button">
        <span class="btn__icon [--btn--icon: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="sr-only">Next</span>
    </button>
</p>
css/patterns/button.css
Button

  <a class="btn" href="…">Label</a>
  <button class="btn btn--secondary">Label <span class="btn__icon"></span></button>
  <button class="btn btn--square"><span class="btn__icon"></span><span class="sr-only">Close</span></button>

Set in Plex Mono (type-label), with Kite Co.'s 6px corners. Colours come
from the role tokens, so a button follows its surface without per-context
overrides. Hover colours are derived with color-mix().

Block knobs, declared here and set by variants:
  --btn--padding-x, --btn--padding-y, --btn--gap, --btn--border-width,
  --btn--border-radius, --btn--background-color, --btn--border-color,
  --btn--color, and each colour's --hover state.

Read with a fallback (set on the button):
  --btn--size        btn--square's minimum size, defaults to 44px. Below
                     44px, add hit-area to keep the target 44px

Icon knobs (set on the button or the icon):
  --btn--icon        url() of a mask image for an empty .btn__icon
  --btn--icon-size   defaults to 1em

Tag

Markup
<ul class="flex-list">
    <li class="tag">Websites</li>
    <li><a class="tag" href="#tag">A linked tag</a></li>
</ul>
css/patterns/tag.css
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>
css/patterns/input.css
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.
css/patterns/select.css
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.
css/patterns/checkbox.css
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).
css/patterns/radio.css
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.

On the page On blue

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>
css/patterns/focus.css
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

A figcaption under the 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>
css/patterns/media-embed.css
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>
css/patterns/mask-icon.css
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).
css/patterns/cross.css
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.

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>
js/disclosure.ts
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.
css/utilities/aria-expanded.css
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

A dialog

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>
js/dialog.ts
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>
css/patterns/animate.css
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.
js/animate.ts
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.