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.
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.
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.
#fffae1
#222
--kh-color--grey--darkest
--kh-color--grey
--kh-color--blue
--kh-color--blue--dark
#734595
--kh-color--sun
#f0b429
#fafafa
#d0d0ce
#fff
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.
<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>
.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>
<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).
<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.
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>
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.
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).
.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>
.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.
.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>
.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>
| Ref | What | When |
|---|---|---|
| IS-01 | An impact story title A secondary line describing it. | 2014 |
| 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.
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.
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.
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.
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.
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.
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.
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.
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.
One encoding wherever entries appear as marks. Type is always shape and colour together, never colour alone.
| 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.
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.
<span class="kh-badge">Badge</span>
<span class="kh-badge">UPPERCASE</span>
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>
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>
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.
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.
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>
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.
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>
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.
<summary> should match your document outline (use <h2>, <h3>, etc. as appropriate).kh-details class handles all styling — no additional classes needed on child elements{% markdown %} blocks inside for rich contentInside .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>
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.
Inline code uses IBM Plex Mono with a white background and slight padding.
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.
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)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> stylingGuidelines:
kh-u-sr-only), call that out in surrounding prose.Defined in: src/components/vf-componenet-rollup/_kh-content.scss (look for .kh-demo). Shortcode in: config/eleventy/markdown.js (config.addPairedShortcode('codeAndDemo', …)).
<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>
.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>
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">
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 |
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>
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>
<fieldset> starts with hidden in production — JavaScript removes it on load (progressive enhancement, since the toggle only works with JS)name must be unique per instance to avoid conflicts: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.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.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.
→ 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">→</span>
</button>
</form>
</div>
</div>
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 nameA chat-bubble conversation pattern used by the semantic search page (/search/). User messages are blue and right-aligned; system responses are white and left-aligned. The container .kh-ask wraps the conversation; .kh-ask__conversation holds the message list; .kh-ask__results holds the result links. Defined in src/components/vf-componenet-rollup/_semantic-search.scss.
<div class="kh-ask">
<div class="kh-ask__conversation">
<p class="kh-ask__message kh-ask__message--user">How does the data-font idea relate to charts?</p>
<p class="kh-ask__message kh-ask__message--system">A few posts cover this. The 2018 sketch was about a font that helps read dense data; Datatype inverts that into a font that is the data.</p>
</div>
<ul class="kh-ask__results">
<li><a href="#">A data font, from the inside out</a></li>
<li><a href="#">What if: A web font for data</a></li>
</ul>
</div>
Used on the blog index and work pages to display post previews.
<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>
A one-sentence teaser that summarizes the post content and entices the reader to click through.
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>
Used on the blog index. Grid layout stacks on mobile, goes side-by-side on screens wider than 600px.
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>
The image uses a 3:2 aspect ratio with object-fit cover. Constrained to 220px wide.
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>
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>
| 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 |
.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>
.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>
.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>
.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>
.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>
.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.
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">← 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 →</a></li>
</ul>
</nav>
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 |
The .kh-content class constrains content to max-width: 70ch for readable line lengths. Add .kh-content--wide to remove the constraint.
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
Logo style — Plex Mono SemiBold
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>
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.
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>
A fixed bottom banner triggered by CSS :target. Used after the feedback Worker redirects back with a #thanks fragment. No JavaScript required.
display: none)#thanks appears in the URL, :target activates the bannerThe 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.
#fafafa background, blue text — matches button paletteborder-top above the banner in --kh-color--blue--dark::after pseudo-element, 4px blue line, shrinks linearly over 15s::before adds a 🎉 prefixkh-banner-out animation fires at 15s, slides the banner down and sets visibility: hiddenposition: fixed breaks out of any container contextClick the link below to trigger the toast. It appends #feedback-toast-demo to the URL, activating the :target state on the demo element.
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.
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.
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>
All interactive elements get a 1px dashed outline with 4px offset on focus. This applies to:
.kh-contentkh-* classThe 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.
Animations (the dappled-light ambience effect and venetian blind sway) are gated behind @media (prefers-reduced-motion: no-preference) and disabled by default.
The ambient background effects are disabled entirely under @media (prefers-contrast: high).
| 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 |
| Class | Effect |
|---|---|
.kh-u-text--nowrap |
Prevents text from wrapping |
.kh-u-text-color--grey |
Sets text color to #707372 |
| 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 |
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.
All custom properties are scoped to body:has(#kh-css-toggle:checked):
| 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 |
| 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 |
| 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) |
| 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 |
Printed direct from AllAboutKen.com