Component reference · Editorial style guide

Component reference

A living reference of every visual pattern on this site. All examples below are rendered live — if something looks wrong here, it looks wrong everywhere.

Colors#

Design tokens defined as CSS custom properties on the body. The site uses a warm paper background (#fffae1) with dark text (#222) and blue accents.

Page background
#fffae1
Body text
#222
Grey darkest
--kh-color--grey--darkest
Grey
--kh-color--grey
Blue (links)
--kh-color--blue
Blue dark
--kh-color--blue--dark
Visited links
#734595
Sun (accent)
--kh-color--sun
Yellow dark
#f0b429
Surface (boxes, tables)
#fafafa
Table header
#d0d0ce
Code/quote bg
#fff

Typography#

All type is set in IBM Plex — Plex Sans for text, Plex Mono for code and the datasheet micro-labels. Both are self-hosted. Base size is 16px with 1.7 line height.

Heading classes#


<h1 class="kh-text-heading--1">Heading 1 — 42px / declared 700, renders SemiBold 600</h1>
<h2 class="kh-text-heading--2">Heading 2 — 30px / 600 weight</h2>
<h3 class="kh-text-heading--3">Heading 3 — 27px / 600 weight</h3>

Heading 1 — 42px / declared 700, renders SemiBold 600

Heading 2 — 30px / 600 weight

Heading 3 — 27px / 600 weight

Content headings (unstyled h-tags inside .kh-content)#

Inside .kh-content, bare heading tags inherit proportional sizing:


<div class="kh-content">
  <h1>Content H1 — 42px</h1>
  <h2>Content H2 — 30px</h2>
  <h3>Content H3 — 27px</h3>
  <h4>Content H4 — 21px</h4>
  <h5>Content H5 — 19px</h5>
</div>

Content H1 — 42px

Content H2 — 30px

Content H3 — 27px

Content H4 — 21px

Content H5 — 19px

Body text classes#


<p class="kh-text-body--1">Body 1 — 32px, weight 500. Used for post teasers and lead text.</p>
<p class="kh-text-body--3">Body 3 — 16px, weight 500. Used for metadata and supporting text in dark grey (#373a36).</p>

Body 1 — 32px, weight 500. Used for post teasers and lead text.

Body 3 — 16px, weight 500. Used for metadata and supporting text in dark grey (#373a36).

Inline text styles#


<p>Regular paragraph text at 16px with 1.7 line height.</p>
<p><em>Italic text uses font-variation-settings for a slanted casual style.</em></p>
<p><strong>Bold text for emphasis.</strong></p>
<p><small>Small text — 13px, used for dates and fine print.</small></p>
<p>Text with <code>inline code</code> rendered in IBM Plex Mono.</p>
<p>An <abbr title="Abbreviation with tooltip">ABBR</abbr> gets a dotted underline and help cursor.</p>

Regular paragraph text at 16px with 1.7 line height.

Italic text uses font-variation-settings for a slanted casual style.

Bold text for emphasis.

Small text — 13px, used for dates and fine print.

Text with inline code rendered in IBM Plex Mono.

An ABBR gets a dotted underline and help cursor.

Intro pair (hero heading + lead text)#

A pair of typography classes for hero / landing-style introductions: kh-intro__heading (42px, weight 700) for the headline and kh-intro__text (22px, weight 300) for the lead paragraph beneath it. Distinct from the standard kh-text-body--* scale — bigger headline, lighter lead, designed to set up the page rather than carry body content. In use on the homepage, the 404 page, and the work index.


<h2 class="kh-intro__heading">A clear, confident statement of what this page is about.</h2>
<p class="kh-intro__text">One paragraph of supporting context, set in a lighter weight at a slightly larger size, so the eye lands here before working into the body.</p>

A clear, confident statement of what this page is about.

One paragraph of supporting context, set in a lighter weight at a slightly larger size, so the eye lands here before working into the body.

Datasheet register#

A small set of opt-in primitives that let a page speak in a technical-document voice. They live in src/components/vf-componenet-rollup/_kh-datasheet.scss. Nothing here restyles an existing component: a page adopts the classes deliberately. The whole register is flat by rule — no radius, no shadow, the hairline is the decoration.

Three colours do the work: --kh-rule--strong for structural borders, --kh-rule for internal dividers, and --kh-color--coral for apparatus (numbers, labels, reference IDs).

Micro-labels and section numbers#

.kh-meta annotates rather than titles. .kh-section-number prefixes a heading; the number is authored in the markup, not generated, so it survives with CSS off and stays under editorial control.


<div class="kh-section-head">
  <h2 class="kh-text-heading--2"><span class="kh-section-number">1.0</span>Recent writing</h2>
  <span class="kh-meta">Latest 10</span>
</div>

1.0Recent writing

Latest 10

.kh-section-head pairs a numbered title with a right-hand annotation and closes it with a hairline. It is what makes a page scan as a document rather than a stack of blocks.

Panels#

.kh-panel is a hairline box. .kh-panel__row divides it, .kh-panel__cell subdivides a row into columns (side by side from the sm breakpoint, stacked below), and .kh-panel__label is the coral mono caption inside a cell.


<div class="kh-panel">
  <div class="kh-panel__row">
    <div class="kh-panel__cell">
      <span class="kh-panel__label">Status</span>
      Open to strategic opportunities.
    </div>
    <div class="kh-panel__cell">
      <span class="kh-panel__label">Find me</span>
      Mastodon, LinkedIn, email.
    </div>
  </div>
</div>
Status Open to strategic opportunities.
Find me Mastodon, LinkedIn, email.

Spec table#

.kh-table--spec opts out of the default .kh-content table styling (the kh- prefix does that), so it starts from a clean slate: mono uppercase column heads, hairline rows, no zebra striping. .kh-table__id is the reference column, .kh-table__note a secondary line under a cell. Wrap in .kh-table-scroll so a wide table scrolls inside itself rather than making the page scroll sideways. Used by /work.


<div class="kh-table-scroll">
  <table class="kh-table--spec">
    <thead>
      <tr><th scope="col">Ref</th><th scope="col">What</th><th scope="col">When</th></tr>
    </thead>
    <tbody>
      <tr>
        <td class="kh-table__id">IS-01</td>
        <td>An impact story title
          <small class="kh-table__note">A secondary line describing it.</small>
        </td>
        <td>2014</td>
      </tr>
    </tbody>
  </table>
</div>
RefWhatWhen
IS-01 An impact story title A secondary line describing it. 2014

Other primitives#

Class Used by Notes
.kh-hero / .kh-hero__spec homepage Hairline masthead with a label/value spec column
.kh-datasheet-list homepage Post list as an outlined grid; promotes the display: contents wrappers in post-summary.njk to real cells
.kh-global-header every page Masthead as a strip of bordered nav cells
.kh-post-head / .kh-doc-ref post pages Hairline masthead, mono byline, coral reference number
.kh-post-rail post pages Sticky numbered contents index in the left margin
.kh-compare Recursive post Before/after image slider. The control is a real <input type="range">, so pointer, touch and keyboard all come from the platform; JS only mirrors its value into --kh-compare-split, which a clip-path reads. With no JS the split stays where the inline custom property puts it

.kh-datasheet-list is opt-in on the container, so post-summary.njk stays untouched and every other page that includes it is unaffected.

Everyday chrome#

Components a reader meets on every entry, previously undocumented here even though they are the most-seen things on the site:

Class Where Notes
.kh-timeline above every entry Publishing history, one mark per month, scrolling and opening on the recent end. Carries a visible .kh-timeline__label linking to /archive/; the marks link to a month within it
.kh-entry-foot foot of every entry The wrapper for what comes after the article: related reading, authorship, sequence
.kh-readnext / .kh-near entry foot "Related posts", computed from the embeddings. Owns the h2
.kh-sequence / .kh-seq entry foot Where this entry sits in the register, with a typed key
.kh-author entry foot Byline and contact, on entries only; index pages get the footer blurb instead
.kh-length-gauge, .kh-topic-tally byline Apparatus — see Labels and micro-copy below

Story Lab components (.kh-lab-*, .kh-radial-*, .kh-trail*, .kh-passage*) are deliberately not documented here. They belong to one noindex experimental page and are described in docs/CORPUS_STORY_LAB.md; promoting them into this guide would imply they are site-wide patterns, which they are not.

Labels and micro-copy#

The seam between the editorial guide and this one. Components carry words, and those words are written, not designed — but they are constrained by the component they sit in, so neither guide owns them alone. The rules live here; the vocabulary lives in Interface vocabulary.

Naming#

One name per thing. A destination gets one section name and its contents get one entry-type name; see the table in the editorial guide. Before adding a label, check whether the thing already has a name somewhere else.

Sentence case, not Title Case#

Buttons, section headings, panel labels, nav items, straplines — everything. "Get in touch", not "Get in Touch". "Impact at a glance", not "Impact at a Glance". The editorial guide already requires sentence case for titles and teasers; interface labels are held to the same rule, because a label is written, not designed.

The exceptions are the ones you would expect and no others: proper nouns and acronyms keep their own capitals. "Download PDF", "Try Foveacast" and "CKEditor clean styles for Drupal" are all correct — the capital belongs to the product, not to the label.

Title Case is where a design language quietly turns corporate. It arrived here via the old button component and spread to the CV's section headings; it did not survive contact with the rest of the register, which is mono micro-labels and plain sentences.

Straplines#

The .kh-meta line beside a section heading states what the reader is looking at, not what it is called — the heading already did that. "Related posts / Suggested by similarity" works because the second half says where the list came from. "Related posts / By meaning, not by tag" did not, because it assumed the reader knows the site has tags; they never see any.

Apparatus must be legible without hover#

The register prints numbers next to things: a similarity score, a topic tally, a length gauge. No meaning may live only in a title attribute. A tooltip does not exist on touch at all, and a reader on a phone met three bare decimals with no way to find out what they were.

If a number needs explaining, explain it once in visible text near the block — a .kh-meta key line under the list, not a tooltip per item. Keep title as a convenience for pointer users, never as the only carrier.

The same rule covers instructions: /stats/ said "hover a point for its title" above 3.5px targets, which is an instruction a phone cannot follow.

Register#

Labels take the mono micro-type (.kh-meta, .kh-panel__label), body copy takes the sans. That contrast is what makes a label read as apparatus rather than as content — which is also why navigation must not use it: nav set in the metadata register reads as a utility toolbar rather than as the masthead.

Data marks#

Micro-visualisations in the register's voice: build-time data baked into markup, no JavaScript, no chart library. Styles live in src/components/vf-componenet-rollup/_kh-dataviz.scss; markup comes from partials in src/site/_includes/partials/ fed by filters registered from config/eleventy/. Every mark carries a text equivalent (visually-hidden text plus a title), so with CSS off each one degrades to a plain sentence or a list of links.

Entry-type key#

One encoding wherever entries appear as marks. Type is always shape and colour together, never colour alone.

article — filled ink square

digesting — open blue square

impact story — coral diamond

The marks#

Mark Where Encoding
Pulse strip (partials/pulse-strip.njk, pulseMonths filter) post masthead Twelve months, one 2px tick per entry — counts are countable, not just proportional. Ticks stack articles / digesting / impact bottom-up; a coral tick below the baseline marks this post's month.
Sequence rail (partials/sequence-mark.njk, neighbourhood filter) post foot This entry and up to three neighbours either side on a hairline, oldest left. At wide viewports, alternating leader lines join each mark to its dated, linked title; narrow viewports retain the compact rail. The current entry wears a filled coral mark and a plain-text label.
Length gauge (partials/length-gauge.njk, lengthFrame filter) post byline Coral marker at this post's percentile rank among all posts-tagged entries; centre tick is the median. Position is the only encoding.
Topic tally (topicTally filter) post byline ×N after a topic: how many entries share it. A ranked list with honest numbers, not a word cloud.

Colours are the register's own: ink #2b2b28 for articles, link blue #3b6fb6 for digesting, coral #bf4a26 for impact stories and for apparatus (the current-month tick, the gauge marker, tallies). SVG fills are hardcoded in the partials because presentation attributes can't read CSS custom properties — keep them in sync with _kh-dataviz.scss.

Buttons#

Flat: a 4px border, no radius, no shadow. It signals state by inverting rather than moving — solid blue on hover and focus, blue-dark on active.


<a href="#buttons" class="kh-button">Default button</a>
<a href="#buttons" class="kh-button kh-button--sm">Small button</a>

Border colour is --kh-button-border-color and fill is --kh-button-background-color, both overridable via custom properties. The old --kh-button-shadow-background-color is unused since the offset shadow was removed.

Badges and labels#


<span class="kh-badge">Badge</span>
<span class="kh-badge">UPPERCASE</span>
Badge UPPERCASE

Labels#

Pill-shaped categorization chips used for topics and tags:


<span class="kh-label">web development</span>
<span class="kh-label">design systems</span>
<span class="kh-label">Eleventy</span>
<span class="kh-label">performance</span>
web development design systems Eleventy performance

Note boxes#

Yellow-bordered callout boxes for asides, updates, and contextual information. The label is set italic in Plex Sans SemiBold.


<aside class="kh-note-box">
  <span class="kh-note-box__label">Update</span>
  This pattern was revised in September 2025 to use Recursive's casual axis for a more handwritten feel. That axis went away with the move to IBM Plex; the label now leans on italics instead.
</aside>

<aside class="kh-note-box">
  <span class="kh-note-box__label">Context</span>
  The label uses a bold monospace casual variant. Keep labels short: "Update", "Sidebar", "Related", "Context".
</aside>

Margin notes and pull-quotes#

Tufte-inspired supplementary asides, set in the datasheet register: a hairline top rule and a coral mono micro-label (NOTE / QUOTE). On a post page the left rail carries the document's index (masthead + contents); margin apparatus takes the opposite gutter, beside the paragraph it supports. On desktop (≥ 1300px) they float into the right margin past the post body; below that — and in print — they render in the flow as hairline blocks. Use notes for supporting citations, secondary references, and context that enriches the argument without interrupting the main flow.

Author with the paired shortcodes inside {% markdown %} blocks — content is rendered as inline markdown, so markdown links, emphasis, code, and -- em dashes all work (no blank lines):

  • {% marginnote %}A citation with a [link](https://example.com).{% endmarginnote %} — indexed and read aloud; real content is allowed.
  • {% pullquote %}A phrase lifted verbatim from the paragraph.{% endpullquote %} — repeats body copy, so it is hidden from assistive tech and search. Never put unique content in one.

Raw HTML (<aside class="kh-marginnote">) still works in older posts. Keep margin notes to 1–2 sentences; for anything longer, use a note box instead. Keep pull-quotes to a single short phrase.

See the editorial style guide for when to reach for margin notes versus inline citations.


<p>This paragraph has a margin note attached. On a wide post page the aside floats off to the right, in the gutter past the main column; on a narrow screen it falls back into the flow as a hairline panel. Either way, it's a Tufte-style sidebar without taking interaction weight.<aside class="kh-marginnote">Edward Tufte's <a href="https://www.edwardtufte.com/tufte/books_vdqi">Visual Display</a> popularised the side-margin annotation as a way to keep references close to the argument they support.</aside></p>

This paragraph has a margin note attached. On a wide post page the aside floats off to the right, in the gutter past the main column; on a narrow screen it falls back into the flow as a hairline panel. Either way, it's a Tufte-style sidebar without taking interaction weight.


<p>This paragraph has a pull-quote attached, repeating its key phrase a register up. It keeps references close to the argument they support, without interrupting the reading line.<aside class="kh-pullquote" aria-hidden="true">Keep references close to the argument they support.</aside></p>

This paragraph has a pull-quote attached, repeating its key phrase a register up. It keeps references close to the argument they support, without interrupting the reading line.

Content boxes#

General-purpose container with a bottom border accent. Themeable via --kh-box-theme-color--background and --kh-box-theme-color--foreground.


<div class="kh-box">
  <p>A content box with default styling — light background, 4px solid bottom border, and 1rem padding.</p>
  <p>Links inside <code>.kh-box__text</code> inherit the current color.</p>
</div>

A content box with default styling — light background, 4px solid bottom border, and 1rem padding.

Links inside .kh-box__text inherit the current color.

Collapsible details#

A styled <details> element for secondary content that readers can expand on demand. Uses the site's blue border to match the button, with no radius or shadow. The heading inside <summary> renders inline next to the disclosure triangle.


<details class="kh-details">
  <summary><h3>Section title goes here</h3></summary>
  <p>This content is hidden by default and revealed when the reader clicks the summary. Use it for supplementary information, prior art, alternative approaches, or anything that would interrupt the main flow.</p>
  <p>Any HTML works inside — lists, code blocks, images.</p>
</details>

Section title goes here

This content is hidden by default and revealed when the reader clicks the summary. Use it for supplementary information, prior art, alternative approaches, or anything that would interrupt the main flow.

Any HTML works inside — lists, code blocks, images.

Open by default#

Add the open attribute to show the content expanded on page load:


<details class="kh-details" open>
  <summary><h3>Expanded by default</h3></summary>
  <p>The open state adds a bottom border below the summary to separate it from the content. The disclosure triangle rotates to indicate the expanded state.</p>
</details>

Expanded by default

The open state adds a bottom border below the summary to separate it from the content. The disclosure triangle rotates to indicate the expanded state.

When to use#

  • Lengthy prior art or background that most readers can skip
  • Alternative approaches considered but not chosen
  • Technical implementation details on a non-technical post
  • Code examples that supplement but aren't essential to the narrative

Markup notes: collapsible details#

  • The heading level inside <summary> should match your document outline (use <h2>, <h3>, etc. as appropriate)
  • The .kh-details class handles all styling — no additional classes needed on child elements
  • Works with {% markdown %} blocks inside for rich content

Blockquotes#

Standard blockquote#

Inside .kh-content, bare blockquotes sit on the panel tint inside a hairline, with a coral rule down the left edge:


<div class="kh-content">
  <blockquote>
    <p>The best way to predict the future is to invent it. Well-structured content is the foundation of every good digital experience.</p>
  </blockquote>
</div>

The best way to predict the future is to invent it. Well-structured content is the foundation of every good digital experience.

A styled blockquote with a yellow left border, opening quote mark, and a hand-drawn highlighter effect behind the text:


<blockquote class="kh-quote--featured">
  <p>The work you're most proud of is rarely the flashiest — it's the system that quietly keeps working long after you've moved on.</p>
</blockquote>

Code#

Inline code#

Inline code uses IBM Plex Mono with a white background and slight padding.

Code blocks#

Fenced code blocks inside .kh-content get a white background inside a hairline border:

.kh-button {
  appearance: none;
  background-color: var(--kh-button-background-color);
  border: 4px solid var(--kh-button-border-color);
  border-radius: 0;
  transition: background-color linear 125ms, color linear 125ms;
}
<aside class="kh-note-box">
  <span class="kh-note-box__label">Note</span>
  Content goes here.
</aside>

Code blocks use pre-wrap for wrapping and overflow: auto for horizontal scroll when needed.

Code + live demo (codeAndDemo shortcode)#

Use the paired codeAndDemo shortcode when you want to show a snippet as both an escaped code block and a live rendered demo from the same source. The shortcode renders a single bordered card with two labelled panes — Source on top, Result below — so the relationship reads at a glance.

Usage (place outside {% markdown %} blocks; markdown will double-escape otherwise):

{% codeAndDemo 'html' %}
<button class="kh-button">Click me</button>
{% endcodeAndDemo %}

The first argument sets the language class for syntax highlighting. Best suited to runnable HTML/JS examples; avoid for Nunjucks template code (which the shortcode would re-evaluate).

Example output:


<p>Status: <span class="kh-badge">Draft</span></p>
<button class="kh-button">Click me</button>

Status: Draft

Structure:

  • .kh-demo — the outer bordered card
  • .kh-demo > pre — the source pane (off-white background, gets the "Source" label via ::before)
  • .kh-demo__live — the result pane (white background, gets the "Result" label via ::before)
  • The code inside .kh-demo > pre has its background overridden to transparent so the source reads as one continuous block instead of per-line rectangles bleeding out of inline <code> styling

Guidelines:

  • Use it once or twice per page where seeing the source next to the rendered output adds value. For pure reference code or where there's no useful "live" component, prefer a plain fenced code block.
  • Keep the snippet self-contained where possible. If it depends on a site-wide utility class (for example kh-u-sr-only), call that out in surrounding prose.
  • Do not duplicate the same example in both a fenced code block and the shortcode — let the shortcode handle both halves.

Defined in: src/components/vf-componenet-rollup/_kh-content.scss (look for .kh-demo). Shortcode in: config/eleventy/markdown.js (config.addPairedShortcode('codeAndDemo', …)).

Figures and images#

Styled figure#


<figure class="kh-figure">
  <img class="kh-figure__image" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='600' height='300' fill='%23d0d0ce'%3E%3Crect width='600' height='300'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' fill='%23707372' font-family='system-ui' font-size='18'%3E600 × 300 placeholder%3C/text%3E%3C/svg%3E" alt="Placeholder image demonstrating the figure component">
  <figcaption class="kh-figure__caption">Caption text — used for attribution and context. Grey (#373a36), weight 500.</figcaption>
</figure>
Placeholder image demonstrating the figure component
Caption text — used for attribution and context. Grey (#373a36), weight 500.

Unstyled figure (inside .kh-content)#

Bare figure tags inside .kh-content use display: table with bottom-aligned captions:


<div class="kh-content">
  <figure>
    <img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='200' fill='%23d0d0ce'%3E%3Crect width='400' height='200'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' fill='%23707372' font-family='system-ui' font-size='16'%3E400 × 200%3C/text%3E%3C/svg%3E" alt="Placeholder image">
    <figcaption>A plain figure caption in grey (#54585a).</figcaption>
  </figure>
</div>
Placeholder image
A plain figure caption in grey (#54585a).

Bleed-out image#

The .kh-bleed-out utility lets an image escape the reading measure on larger screens. It bleeds left only, into the contents rail — that is where the room is. The reading column sits right of centre in the three-column post grid, so a symmetric percentage of the container says nothing about the space actually beside it, and bleeding both ways scrolled the whole page sideways at mid-range widths.

Where it meets the sticky contents rail, the rail wins: it carries an opaque fill and a higher z-index, so a wide figure slides underneath a pinned card. Navigation that blinks out as you scroll reads as a bug; a figure passing behind something reads as depth. The figure is far wider than the overlap, so nothing is lost.

Do not put .kh-figure inside .kh-content. .kh-figure is a three-column grid — correct as a page-level component, wrong in prose, where it silently squeezes a figure into a third of the measure and makes its type unreadable. In body copy use a bare <figure>, or <figure class="kh-bleed-out"> for one that should escape the measure. The stylesheet also neutralises the grid in that context, so a mistake degrades rather than corrupts — but the rule is the primary defence, and the reset is the seatbelt.


<img class="kh-bleed-out" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='900' height='300' fill='%23d0d0ce'%3E%3Crect width='900' height='300'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' fill='%23707372' font-family='system-ui' font-size='18'%3EBleed-out image (wider on large screens)%3C/text%3E%3C/svg%3E" alt="Demonstration of bleed-out image effect">
Demonstration of bleed-out image effect

Tables#

A wide .kh-table--spec can also take .kh-table--stack, which makes it stop being a table below the md breakpoint: each row becomes a bordered card, the header row is visually hidden but still announced, and cells name themselves from data-label. Cells that are self-evident — a reference number, a title — carry no data-label and so get no name.

Reach for it when horizontal scrolling would hide more than it shows. /work/ is the case that prompted it: as a five-column table on a phone it hid 209px of its 565px width, collapsing the summary column to two-word lines and slicing the topic chips mid-word against the container edge, which reads as broken rendering rather than as an invitation to scroll.

It is opt-in for the same reason .kh-datasheet-list is: most spec tables are narrow enough that the scroller is the better answer.

Inside .kh-content, bare tables get alternating row striping with a grey header row:


<div class="kh-content">
  <table>
    <thead>
      <tr>
        <th scope="col">Property</th>
        <th scope="col">Value</th>
        <th scope="col">Notes</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td>Background</td>
        <td><code>#fafafa</code></td>
        <td>Light surface color</td>
      </tr>
      <tr>
        <td>Header bg</td>
        <td><code>#d0d0ce</code></td>
        <td>Warm grey</td>
      </tr>
      <tr>
        <td>Even rows</td>
        <td><code>#d8d8d8</code></td>
        <td>Alternating stripe</td>
      </tr>
      <tr>
        <td>Cell padding</td>
        <td><code>8px 16px</code></td>
        <td>Comfortable reading</td>
      </tr>
    </tbody>
  </table>
</div>
Property Value Notes
Background #fafafa Light surface color
Header bg #d0d0ce Warm grey
Even rows #d8d8d8 Alternating stripe
Cell padding 8px 16px Comfortable reading

Forms#

Form elements used for the site search. The search input has a custom SVG cancel button on WebKit browsers.


<div class="kh-form__item">
  <label class="kh-form__label" for="demo-search">Search</label>
  <input type="search" id="demo-search" placeholder="Search my site" class="kh-form__input">
</div>
<a href="#forms" class="kh-button">Search</a>
Search

Search mode toggle#

A tabbed radio control used on the search page to switch between keyword and semantic search. On the search page, the tabs sit above a .kh-search-widget__panel card — the active tab merges into the panel via a matching background and erased bottom border. Hidden radio inputs keep it accessible — keyboard users can arrow between options and screen readers announce the selected state.


<div class="kh-search-widget">
  <fieldset class="kh-search-mode">
    <legend class="kh-u-sr-only">Search mode</legend>
    <label class="kh-search-mode__option">
      <input type="radio" name="demo_search_mode" value="keyword" checked>
      Search by keyword
    </label>
    <label class="kh-search-mode__option">
      <input type="radio" name="demo_search_mode" value="semantic">
      Search semantically
    </label>
  </fieldset>
  <div class="kh-search-widget__panel">
    <p style="margin:0;color:var(--kh-color--grey)">Panel content goes here</p>
  </div>
</div>
Search mode

Panel content goes here

Markup notes: search mode toggle#

  • The <fieldset> starts with hidden in production — JavaScript removes it on load (progressive enhancement, since the toggle only works with JS)
  • Radio name must be unique per instance to avoid conflicts
  • The active state uses :has(input:checked) to style the parent label — no JS needed for the visual toggle
  • :has(input:focus-visible) provides a sun-yellow outline for keyboard focus
  • Wrap in .kh-search-widget with a .kh-search-widget__panel; the tab strip's bottom edge is the panel's top edge, so the two read as one object

Search form#

.kh-search-widget__form is a shared flex layout used by both the keyword and semantic search inputs. The button includes two inner spans — .kh-button__label (text) and .kh-button__icon (unicode arrow) — that swap at the mobile breakpoint.

  • Desktop: full text button ("Search" / "Ask") sharing one hairline row with the input
  • Mobile (<600px): text hidden, button becomes a small blue circle with a arrow, positioned inside the input field

<div class="kh-search-widget">
  <div class="kh-search-widget__panel">
    <form class="kh-search-widget__form" onsubmit="return false">
      <label class="kh-u-sr-only" for="demo-search-toggle">Search</label>
      <input type="search" id="demo-search-toggle" placeholder="Search my site" class="kh-form__input">
      <button type="submit" class="kh-button" aria-label="Search">
        <span class="kh-button__label" aria-hidden="true">Search</span>
        <span class="kh-button__icon" aria-hidden="true">&#x2192;</span>
      </button>
    </form>
  </div>
</div>
  • Always include aria-label on the button so the purpose is announced when only the icon is visible
  • .kh-button__label and .kh-button__icon both carry aria-hidden="true" — the aria-label is the accessible name
  • Resize to a narrow viewport to see the icon-inside-input behaviour

Summary cards#

Used on the blog index and work pages to display post previews.

Standard summary#


<article class="kh-summary">
  <div class="kh-summary__date">7 Feb 2026</div>
  <h3 class="kh-summary__title">
    <a href="#summary-cards" class="kh-summary__link">Example post title with balanced text wrapping</a>
  </h3>
  <p class="kh-summary__text">A one-sentence teaser that summarizes the post content and entices the reader to click through.</p>
</article>

News summary#

A more compact variant with date on its own row and two-column layout on wider screens:


<article class="kh-summary kh-summary--news">
  <div class="kh-summary__date">7 Feb 2026</div>
  <h3 class="kh-summary__title">
    <a href="#summary-cards" class="kh-summary__link">News-style summary with tighter spacing</a>
  </h3>
  <p class="kh-summary__text">Used on the blog index. Grid layout stacks on mobile, goes side-by-side on screens wider than 600px.</p>
</article>

Summary with image#

Summaries can include a thumbnail image that snaps to the right column on medium+ screens:


<article class="kh-summary kh-summary--news">
  <div class="kh-summary__date">7 Feb 2026<span class="kh-summary__author">Filed in: design systems, performance</span></div>
  <h3 class="kh-summary__title">
    <a href="#summary-cards" class="kh-summary__link">Post with a thumbnail image</a>
  </h3>
  <a href="#summary-cards" class="kh-summary__image">
    <img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='147' fill='%23d0d0ce'%3E%3Crect width='220' height='147'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' fill='%23707372' font-family='system-ui' font-size='14'%3E220×147%3C/text%3E%3C/svg%3E" alt="Example thumbnail">
  </a>
  <p class="kh-summary__text">The image uses a 3:2 aspect ratio with object-fit cover. Constrained to 220px wide.</p>
</article>
7 Feb 2026Filed in: design systems, performance

Post with a thumbnail image

Example thumbnail

The image uses a 3:2 aspect ratio with object-fit cover. Constrained to 220px wide.

Metrics#

Used on the CV page to display key impact numbers. Cards have a subtle hover lift effect.


<div class="kh-metrics-grid">
  <div class="kh-metric">
    <span class="kh-metric__number">20+</span>
    <span class="kh-metric__label">YEARS EXPERIENCE</span>
  </div>
  <div class="kh-metric">
    <span class="kh-metric__number">0.45s</span>
    <span class="kh-metric__label">PAGE LOAD TIME</span>
  </div>
  <div class="kh-metric">
    <span class="kh-metric__number">3 weeks</span>
    <span class="kh-metric__label">EDITORIAL TIME SAVED / MONTH</span>
  </div>
</div>
20+ YEARS EXPERIENCE
0.45s PAGE LOAD TIME
3 weeks EDITORIAL TIME SAVED / MONTH

Snapshot list#

A timeline-style list for career milestones, with bold labels and bottom borders:


<div class="kh-career-snapshot">
  <ul class="kh-snapshot-list">
    <li><strong>2023 – present</strong> Platform Architect at UNDRR</li>
    <li><strong>2018 – 2023</strong> Web Design Architect at EMBL</li>
    <li><strong>2015 – 2018</strong> Lead Developer at EMBL-EBI</li>
  </ul>
</div>
  • 2023 – present Platform Architect at UNDRR
  • 2018 – 2023 Web Design Architect at EMBL
  • 2015 – 2018 Lead Developer at EMBL-EBI

Layout#

Breakpoints#

Token Value Usage
$kh-breakpoint--sm 600px Small screens up
$kh-breakpoint--md 846px Medium screens up
$kh-breakpoint--lg 1024px Large screens up
$kh-breakpoint--xl 1300px Extra large
$kh-breakpoint--sm-down 599px Below small
$kh-breakpoint--md-down 845px Below medium
$kh-breakpoint--lg-down 1023px Below large

Grid system (.kh-grid)#

The base grid uses CSS Grid with auto-flow columns. Children reset their margin and max-width.


<div class="kh-grid" style="--kh-page-grid-gap: 1rem;">
  <div style="background: #d0d0ce; padding: 1rem;">Column 1</div>
  <div style="background: #d0d0ce; padding: 1rem;">Column 2</div>
  <div style="background: #d0d0ce; padding: 1rem;">Column 3</div>
</div>
Column 1
Column 2
Column 3

Three-column grid (.kh-grid__col-3)#

Explicit three-column layout. Collapses to single column below 846px. Use .kh-grid__col--span-1 and .kh-grid__col--span-2 for asymmetric layouts:


<div class="kh-grid kh-grid__col-3" style="--kh-page-grid-gap: 1rem;">
  <div class="kh-grid__col--span-1" style="background: #d0d0ce; padding: 1rem;">Span 1</div>
  <div class="kh-grid__col--span-2" style="background: #f0b429; padding: 1rem;">Span 2 (wider)</div>
</div>
Span 1
Span 2 (wider)

Stylized grid (.kh-grid-stylized)#

A left gutter (16rem on desktop) with main content to the right. This page and most interior pages use it. The homepage and /work no longer do: they run full width in the main content area, since the datasheet grids and spec tables want the whole measure.


<div class="kh-grid-stylized" style="border: 2px dashed #d0d0ce; padding: 1rem;">
  <div style="background: #d0d0ce; padding: 1rem; text-align: center;">← Gutter (16rem)</div>
  <div style="background: #f0b429; padding: 1rem;">Main content area</div>
</div>
← Gutter (16rem)
Main content area

Stack (.kh-stack)#

Vertical rhythm utility. Adds 1rem margin between sibling children. Use .kh-stack--800 for 2rem spacing:


<div class="kh-stack" style="border: 2px dashed #d0d0ce; padding: 1rem;">
  <div style="background: #d0d0ce; padding: 1rem;">Item 1</div>
  <div style="background: #d0d0ce; padding: 1rem;">Item 2 (1rem gap)</div>
  <div style="background: #d0d0ce; padding: 1rem;">Item 3 (1rem gap)</div>
</div>
Item 1
Item 2 (1rem gap)
Item 3 (1rem gap)

Cluster (.kh-cluster)#

Horizontal flex layout with wrapping and centre alignment. Used for navigation and grouped elements.

Spacing comes from gap (--kh-page-grid-gap, 2rem). Direct children are reset to margin: 0 so an item's own outer margin — .kh-button has one — does not add to that gap. Nothing else is imposed: an item is free to lay out its own contents, which was not true before, when the cluster forced display: flex and an !important margin on children and grandchildren. For a denser row, write a purpose-built flex or grid class rather than overriding this one.


<div class="kh-cluster">
  <span class="kh-label">Item 1</span>
  <span class="kh-label">Item 2</span>
  <span class="kh-label">Item 3</span>
  <span class="kh-label">Item 4</span>
  <span class="kh-label">Item 5</span>
</div>
Item 1 Item 2 Item 3 Item 4 Item 5

Full bleed (.kh-u-fullbleed)#

Makes a section's background extend to the full viewport width while keeping content constrained. Used for the post title bar and footer:


<div class="kh-u-fullbleed kh-u-background-color--yellow--dark" style="padding: 2rem 0;">
  <p style="max-width: 70ch;"><strong>Full bleed section.</strong> The yellow background extends edge-to-edge while the text stays within the content width.</p>
</div>

Full bleed section. The yellow background extends edge-to-edge while the text stays within the content width.

Pager#

Navigation between paginated lists (blog index, work archive). Wrap in <nav class="kh-pager" aria-label="…"> with a list of .kh-pager__link items. The currently active page gets the .kh-pager__link--current modifier and aria-current="page". Used by src/site/_includes/partials/pager.njk; the layout templates wire it up automatically when pagination is in play.


<nav class="kh-pager" aria-label="Blog pagination (example)">
  <ul class="kh-pager__list">
    <li><a href="#" class="kh-pager__link">&larr; Newer</a></li>
    <li><a href="#" class="kh-pager__link">1</a></li>
    <li><a href="#" class="kh-pager__link kh-pager__link--current" aria-current="page">2</a></li>
    <li><a href="#" class="kh-pager__link">3</a></li>
    <li><a href="#" class="kh-pager__link">Older &rarr;</a></li>
  </ul>
</nav>

Spacing utilities#

Utility classes for margin and padding. The naming follows a sizing scale.

Class Property Value
.kh-u-margin__bottom--200 margin-bottom 0.5rem
.kh-u-margin__bottom--600 margin-bottom 1.5rem
.kh-u-margin__top--1200 margin-top 3rem
.kh-u-padding__bottom--200 padding-bottom 0.75rem
.kh-u-padding__bottom--400 padding-bottom 1rem
.kh-u-padding__top--500 padding-top 1.5rem
.kh-u-padding__top--600 padding-top 2rem
.kh-u-padding__top--800 padding-top 3rem

Content width#

The .kh-content class constrains content to max-width: 70ch for readable line lengths. Add .kh-content--wide to remove the constraint.

Font variations#

IBM Plex ships no variable build, so the site loads four static faces. These helper classes select among them:


<p class="kh-font-headline" style="font-size: 32px;">Headline style — Plex Sans SemiBold, tightened tracking</p>
<p class="kh-font-code">Code style — Plex Mono Regular</p>
<p class="kh-logo" style="font-size: 20px;">Logo style — Plex Mono SemiBold</p>

Headline style — Plex Sans SemiBold, tightened tracking

Code style — Plex Mono Regular

Display headlines#

Extra-large headings using .kh-font-headline--display, fluidly sized with clamp(). When two display headlines are adjacent, the first scales larger:


<h2 class="kh-font-headline kh-font-headline--display">Display headline — clamp(2.25rem, 6vw, 3.75rem)</h2>

Display headline — clamp(2.25rem, 6vw, 3.75rem)

Loaded faces reference#

IBM Plex has no variable build, so each weight is a separate file. Only the weights below exist:

Family Weight Style File
IBM Plex Sans 400 normal IBMPlexSans-Regular.woff2
IBM Plex Sans 400 italic IBMPlexSans-Italic.woff2
IBM Plex Sans 600 normal IBMPlexSans-SemiBold.woff2
IBM Plex Mono 400 normal IBMPlexMono-Regular.woff2

There is no 500. Declaring font-weight: 500 resolves to Regular 400, which silently flattens a heading to body weight — use 600 for emphasis. font-weight: 700 resolves to SemiBold 600. There is no bold italic; browsers synthesise one for <em><strong>. See docs/FONTS.md.

Video embed#

The .kh-video wrapper provides a responsive 16:9 container. Iframes, objects, and embeds are positioned absolutely to fill the container:


<div class="kh-video">
  <div style="position:absolute;top:0;left:0;width:100%;height:100%;background:#d0d0ce;display:flex;align-items:center;justify-content:center;color:#707372;font-size:18px;">16:9 video placeholder</div>
</div>
16:9 video placeholder

Feedback toast#

A fixed bottom banner triggered by CSS :target. Used after the feedback Worker redirects back with a #thanks fragment. No JavaScript required.

How it works#

  1. The element is hidden by default (display: none)
  2. When #thanks appears in the URL, :target activates the banner
  3. Banner slides up from the bottom with a spring easing (0.6s)
  4. A blue timer bar along the top border shrinks from 100% to 0 over 15 seconds
  5. After 15 seconds the banner slides back down and hides

Markup#

The element needs an id that matches the URL fragment, the .kh-feedback-thanks class, and aria-live="polite" so screen readers announce the message.

<span id="thanks" class="kh-feedback-thanks" aria-live="polite">
  Thanks for the feedback!
</span>

The feedback link redirects back to the page with the fragment:

<a href="https://feedback.example.com/up/posts/my-post/"
   rel="nofollow">👍 This was useful</a>

The Worker returns 302 Location: https://example.com/posts/my-post/#thanks, which activates :target on the banner element.

Design details#

  • Surface: #fafafa background, blue text — matches button palette
  • Accent rule: a flat 6px border-top above the banner in --kh-color--blue--dark
  • Timer bar: ::after pseudo-element, 4px blue line, shrinks linearly over 15s
  • Emoji: ::before adds a 🎉 prefix
  • Auto-dismiss: kh-banner-out animation fires at 15s, slides the banner down and sets visibility: hidden
  • Reusable: works anywhere — the position: fixed breaks out of any container context

Demo

Click the link below to trigger the toast. It appends #feedback-toast-demo to the URL, activating the :target state on the demo element.

Trigger toast demo

Thanks for the feedback!

Atmospheric background (dappled light)#

The site's ambient background effect: scroll-driven dappled leaf shadows and shifting venetian-blind highlights that animate as the page scrolls. CSS-only, using scroll-driven animations and CSS custom properties. Defined in src/components/kh-ambience/_kh-ambience.scss and injected once via the base layout — you do not need to add it manually to individual pages.

Markup is a single absolutely-positioned div near the top of <body>:

<div id="kh-dappled-light" aria-hidden="true"></div>

It is decorative and aria-hidden, so it does not appear in the accessibility tree. The effect is documented in more depth in the post Sunlit dappled-light effect if you want to see how it works.

Do not add a second #kh-dappled-light to a page; the ID is referenced by the global styles and CSS expects exactly one.

Accessibility patterns#

A hidden link that becomes visible on focus, allowing keyboard users to skip to main content:

<a href="#main-content" class="kh-u-sr-only kh-skip-link">Skip to main content</a>

Tab to the top of this page to see it in action.

Screen reader only#

The .kh-u-sr-only class visually hides content while keeping it accessible to screen readers:

<h2 class="kh-u-sr-only">Section title visible only to screen readers</h2>

Focus indicators#

All interactive elements get a 1px dashed outline with 4px offset on focus. This applies to:

  • All links inside .kh-content
  • All elements with a kh-* class

CSS toggle#

The site's CSS can be toggled off via a checkbox in the footer (#kh-css-toggle). All custom styles are scoped under body:has(#kh-css-toggle:checked), so unchecking it reveals the bare HTML structure — useful for testing structural accessibility.

Reduced motion#

Animations (the dappled-light ambience effect and venetian blind sway) are gated behind @media (prefers-reduced-motion: no-preference) and disabled by default.

High contrast#

The ambient background effects are disabled entirely under @media (prefers-contrast: high).

Utility classes#

Visibility#

Class Behavior
.kh-u-show-for-mobile-only Visible below 600px, hidden above
.kh-u-show-for-medium-up Visible 846px and up
.kh-u-show-for-medium-only Visible 846px – 1023px only
.kh-u-show-for-large Visible 1024px and up
.kh-u-show-for-print-only Hidden on screen, visible when printed
.kh-u-do-not-print Visible on screen, hidden when printed
.kh-u-sr-only Visually hidden, accessible to screen readers

Text#

Class Effect
.kh-u-text--nowrap Prevents text from wrapping
.kh-u-text-color--grey Sets text color to #707372

Color#

Class Effect
.kh-u-background-color--yellow--dark Background #f0b429

The .kh-link class provides blue link styling with purple visited state, independent of .kh-content context:

<a href="/example" class="kh-link">Styled link</a>
Class Purpose
.kh-navigation Navigation wrapper, pushed to right with margin-left: auto
.kh-navigation__list Flex list with gap spacing, no bullets
.kh-navigation__item Inline-flex list item
.kh-navigation__link Link with no underline, underlines on hover
.kh-navigation__link--active Bold weight for current page

Print#

Links in .kh-content lose their decoration on print. Elements with .kh-u-do-not-print are hidden. The .ken-link--print class shows the href URL below the link text.


CSS custom properties reference#

All custom properties are scoped to body:has(#kh-css-toggle:checked):

Color properties#

Property Default
--kh-color--grey #707372
--kh-color--grey--darkest #373a36
--kh-color--blue #3b6fb6
--kh-color--blue--dark #193f90
--kh-color--sun #ffd21e
--kh-color--sun--deep #8f6600
--kh-color--yellow--dark #f0b429
--kh-color--coral #bf4a26
--kh-ink-muted #5b5e5a
--kh-rule #ded2a8
--kh-rule--strong #2b2b28
--kh-panel-bg #fdf5d6
--kh-color--orange alias of --kh-color--sun, deprecated

Layout properties#

Property Default Notes
--kh-body-width 81.25em Max page width
--kh-page-grid-gap 1rem / 2rem Grid gap (responsive)
--kh-grid-stylized-module--prime 16rem Left gutter width

Spacing#

Property Default Notes
--kh-stack-margin 1rem Vertical stack gap
--kh-text-margin--bottom 16px Text element bottom margin
--kh-box-padding 1rem Box component padding
--kh-page-grid-gap 1rem, 2rem at lg Cluster item spacing (gap on .kh-cluster)

Theming#

Property Purpose
--kh-box-theme-color--background Box background override
--kh-box-theme-color--foreground Box text color override
--kh-button-background-color Button background
--kh-button-border-color Button border
--kh-font-sans IBM Plex Sans stack
--kh-font-mono IBM Plex Mono stack

Editorial style guide Colophon