Styleguide — Living pattern reference

A living reference of every design token, component and utility that ships in the sass folder. Everything below is rendered with the same stylesheet the rest of the site uses, so it reflects the current state of the CSS — including the light/dark themes (use the toggle in the banner).

Last updated: 2026-08-11

Colours

styles.scss (:root, dark-theme-cvars) · _variables.scss

Two Sass variables seed a set of semantic custom properties, which the dark theme then overrides wholesale. Swatches below read the live values, so they follow the theme toggle.

Theme anchors

Two hues drive the whole palette. Each is also exposed as --*-theme-color--red|green|blue channels so rgba() tints can be mixed in CSS.

  • --light-theme-color Light theme anchor #ffc036
  • --dark-theme-color Dark theme anchor #4a525a

Surfaces

  • --page-bg-color Page background
  • --header-bg-color Banner + footer background
  • --box-bg-color Translucent box fill
  • --box-border-color Article/section wrapper fill
  • --section-alt-color Alternate section tint
  • --quote-bg-color Blockquote / .c-quote fill
  • --code-bg-color Inline <code> fill

Text

  • --text-color Body text
  • --header-title-color Banner title
  • --header-meta-color Banner meta
  • --text-meta-color Meta text (dates, comments)
  • --title-meta-color Title meta, category tabs, rules
  • --standout-color Standout / .notice
  • --snow-color Snowfall

Links

  • --link-color Link accent
  • --link-color-alt Alternate link accent
  • --link-bg-color Link hover/focus background

Tables

  • --table-stripe-bg Striped row fill
  • --table-hover-bg Hover / active row fill

Forms & buttons

  • --form-bg Input background
  • --form-color Input text
  • --form-border-color Input border
  • --btn-color .btn-alt text
  • --btn-bg .btn-alt background
  • --btn-hover-color .btn-alt hover text
  • --btn-hover-bg .btn-alt hover background

Grey scale (Sass only)

Declared in _variables.scss and resolved at build time. Only $gray-100 and $gray-800 currently reach the browser, as the light and dark page backgrounds.

  • $white #fff
  • $gray-100 #f8f9fa
  • $gray-200 #e9ecef
  • $gray-300 #dee2e6
  • $gray-400 #ced4da
  • $gray-500 #adb5bd
  • $gray-600 #6c757d
  • $gray-700 #495057
  • $gray-800 #343a40
  • $gray-900 #212529
  • $black #000

Typography

_variables.scss · styles.scss

Two families, one modular scale anchored on 18px, and a body size that steps up to 21px at the md breakpoint.

Font files

Google fonts: PT Sans Narrow and Roboto Condensed.

The fonts files are self-hosted, thanks to gwfh.mranftl.com/fonts, and their sizes were reduced with intelligent glyph subsetting at fontsubset.com.

Families

$header-font-family
PT Sans Narrow — headings, nav, meta, captions, tags, blockquotes
$body-font-family
Roboto Condensed — body copy, form controls and, unusually, inline <code>
$mono-font-family
Menlo, monospace — <kbd> and <pre> only

Scale

All sizes are multiples of $font-size-base (1.125rem / 18px). The body steps up to 1.3125rem (21px) at the md breakpoint, so px equivalents shift on wide screens.

Type scale as declared in _variables.scss
TokenrempxUsed by
$font-size-huge 4rem 64px h1 / .branding__title from 48rem up
$font-size-big 3.5rem 56px .h2 (article title) from 48rem up
$font-size-xxl 3rem 48px h1 / .branding__title below 48rem
$font-size-xl 2.25rem 36px .h2 below 48rem, h2 from 48rem up, .section__content__title
(literal) 2rem 32px h2 below 48rem
$font-size-lg 1.5rem 24px h3, .post__title, .branding__tagline
$font-size-md 1.3125rem 21px h4 from 48rem up, body from 48rem up
$font-size-base 1.125rem 18px body, h4 below 48rem
$font-size-sm 1rem 16px excerpts, .small, .text-sm, meta
$font-size-xs 0.875rem 14px categories, tags, figcaption, nav
$font-size-xxs 0.75rem 12px .article__hero--caption
$font-size-tiny 0.625rem 10px unused

Headings

The .h1.h4 classes let semantics and appearance be chosen independently — the article title is an <h1> carrying .h2. .h5 and .h6 are declared but empty.

h2 and .h2 are the one pair that deliberately differ. .h2 is the display size (56px) used for article titles; the bare h2 element is a section break inside body copy (32/36px) and shouldn't compete with the title above it. Write <h2> in post content and it is already the right size — no modifier needed.

Heading level 1

Display size — .h2

Heading level 2

Heading level 3

Heading level 4

Heading level 5 (unstyled)
Heading level 6 (unstyled)

Weights

TokenValueApplied to
$font-weight-thin 100 .c-pinboard__title
$font-weight-light 300 body default ($font-weight-base)
$font-weight-normal 400 figcaption, .legend, meta
$font-weight-bold 500 <strong>
$header-font-weight-bold 700 all headings

Measure & rhythm

$line-height-base
1.56
$article-body-max-width
65ch — the measure of .body
$max-content-width
48rem / 768px — banner and footer
$max-content-lg-width
90rem / 1440px — banner and footer above xl
$article-max-width
48rem / 768px — article wrapper
$article-max-lg-width
1440px — article wrapper above xl

Space, breakpoints & motion

_variables.scss · _easings-opt.scss

Cascade layers

Every rule in every bundle sits in one of five layers, declared once in _layers.scss and re-declared by each entry point so the order holds however the three stylesheets arrive. Later layers win regardless of specificity — which is why .hidden is a plain display: none rather than an !important.

LayerWhat belongs here
resetNormalize and the H5BP defaults. Never site-specific.
baseTheme tokens, typography, and bare-element defaults — html, body, img, button.
vendorThird-party CSS: lightbox, cocoen, the video embeds.
componentsAlmost everything you will write. The default choice.
utilitiesOnly rules that must beat a component. Currently three classes.

The one way to get this wrong: a rule written outside any layer beats every layer, including utilities. There is no warning — it silently wins. If a new rule is overriding something it should not, check that it is inside a @layer block before reaching for specificity.

vendor sits above base deliberately. Layer order beats specificity, so with vendor at the bottom a bare button {} in our own CSS would override .sl-wrapper .sl-close — which specificity used to prevent. Third-party CSS scopes its generic selectors inside its own containers, so it cannot reach site markup from up there, and components still sits above it for deliberate overrides such as the dark-theme lightbox rules.

Layout padding

$article-padding
1.25rem / 20px — article gutter; also the unit for negative "breakout" margins
$article-figcaption-padding
0.5rem
$article-figure-wide
-10rem — how far .wide figures break out at xl

Breakpoints

Defined as a Sass map and mirrored into $bp-* variables, because custom properties cannot be used inside media queries. Everything is min-width / mobile first except the .c-photoset grid overrides, which are max-width.

NamerempxWhat changes
$bp-xs 30rem 480px banner switches to a row, .o-grid goes horizontal, main pulls up 16rem
$bp-sm 37.5rem 600px lists lose their inline padding, .img__inline-sm--* starts floating
$bp-md 48rem 768px body type steps to 21px, headings jump a step, .posts__list__item becomes a row
$bp-lg 50rem 800px wider negative margins for .wide figures
$bp-xl 77rem 1232px article header becomes two columns, hero rotates, footer clip-path deepens

Motion

_easings-opt.scss is Open Props' easing set with all but six declarations commented out. The survivors are used exclusively by the theme toggle; every other transition in the stylesheet uses a literal cubic-bezier() or a keyword. All animation is neutralised under prefers-reduced-motion: reduce.

--ease-3
cubic-bezier(0.25, 0, 0.3, 1)
--ease-out-5
cubic-bezier(0, 0, 0, 1)
--ease-elastic-out-3 (alias --ease-elastic-3)
cubic-bezier(0.5, 1.25, 0.75, 1.25)
--ease-elastic-out-4 (alias --ease-elastic-4)
cubic-bezier(0.5, 1.5, 0.75, 1.25)

Recurring literal timings:

Link hover, banner & footer
0.15s cubic-bezier(0.33, 0.66, 0.66, 1)
Category tabs, dark-mode images
0.5s ease-in-out
Thumbnails
transform 0.25s ease-out, box-shadow 0.5s ease-out

Article chrome

styles.scss (Main, Layout) · site/snippets/article_inc.php

The article wrapper is a two-column layout above xl: header and hero share the top row, the body sits in a 65ch measure and harvested links fill the gutter beside it.

Article header #

Rendered by site/snippets/article_inc.php. The h1 carries .h2, and the microformat classes (p-name, u-url, dt-published, p-category, e-content) are load-bearing for IndieWeb consumers, not styling. .article__author is set to display: none via @extend .hidden — it exists for the h-card only. Above xl the whole block is indented 25%, unless the article has .nopic.

A representative post title

Markup
<header class="sg-header-demo">
  <h1 class="article__title p-name h2"><a href="#sg-article-header" class="u-url" rel="bookmark">A representative post title</a>
    <time class="article__date dt-published" datetime="2026-03-12">— 12 Mar 2026</time>
  </h1>
  <p class="article__author">By <a href="#sg-article-header" class="p-author h-card">David Roessli</a></p>
  <p class="article__category p-category"><span class="sr-only">Posted in </span>
    <a href="#sg-article-header" class="p-category">thoughts</a>
  </p>
  <ul role="list" class="article__meta list-inline">
    <li class="list-inline-item"><a href="#sg-article-header" rel="tag">css</a></li>
    <li class="list-inline-item"><a href="#sg-article-header" rel="tag">archive</a></li>
  </ul>
  <p class="article__meta">A meta caption sits below the tags</p>
</header>

Page (non-post) header #

When isPost is false the <time> is replaced by .section__content__title__meta, which shares its styling.

About — Sept 2025

Markup
<header class="sg-header-demo">
  <h1 class="article__title p-name h2"><a href="#sg-page-non-post-header" class="u-url" rel="bookmark">About</a>
    <span class="section__content__title__meta">— Sept 2025</span>
  </h1>
</header>

Pinned post marker #

The .is--pinned span sits inside .article__category and uses --standout-color rather than the category tab colours.

Markup
<p class="article__category p-category">
  <a href="#sg-pinned-post-marker" class="p-category">meta</a>
  <span class="is--pinned" title="This is a pinned post, not the latest"><span class="sr-only">Pinned post</span><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path d="M298.028 214.267L285.793 96H328c13.255 0 24-10.745 24-24V24c0-13.255-10.745-24-24-24H56C42.745 0 32 10.745 32 24v48c0 13.255 10.745 24 24 24h42.207L85.972 214.267C37.465 236.82 0 277.261 0 328c0 13.255 10.745 24 24 24h136v104.007c0 1.242.289 2.467.845 3.578l24 48c2.941 5.882 11.364 5.893 14.311 0l24-48a8.008 8.008 0 0 0 .845-3.578V352h136c13.255 0 24-10.745 24-24-.001-51.183-37.983-91.42-85.973-113.733z"/></svg></span>
</p>

Hero — .article__hero #

Bleeds past the article padding on phones and, above xl, takes the right half of the two-column article and rotates -2°. The caption is absolutely positioned in the bottom-right corner and progressively reveals its <a> at md and its <b> at xl. Shown here in a bounded box so it does not escape the styleguide layout.

Photo credit — extra detail source

Section title #

Sections that follow [role="main"] get a larger title at md. section.no-clip drops the angled clip-path and the translucent fill; section.travel and section.links are the tinted variants.

Section title — with meta

Markup
<h2 class="section__content__title">Section title&nbsp;<span class="section__content__title__meta">— with meta</span></h2>

Components

styles.scss (Typography, Lists, Sections, Buttons, Table)

Inline text #

Bold uses $font-weight-bold (500). <b> and <i> are deliberately reset to normal weight and style. Marked text, small text, .text-sm, inline code, Cmd + K and a .notice.

Markup
<p><strong>Bold</strong> uses <code>$font-weight-bold</code> (500).
<b>&lt;b&gt;</b> and <i>&lt;i&gt;</i> are deliberately reset to normal weight and
style. <mark>Marked text</mark>, <small>small text</small>,
<span class="text-sm">.text-sm</span>, <code>inline code</code>,
<kbd>Cmd&nbsp;+&nbsp;K</kbd> and a <span class="notice">.notice</span>.</p>

Blockquote #

Set in the heading font with a hanging “ glyph positioned outside the text block, so it needs the surrounding 2rem margin to avoid clipping.

Nobody ever figures out what life is all about, and it doesn't matter.

Richard Feynman — letter, 1966

Markup
<blockquote>
  <p>Nobody ever figures out what life is all about, and it doesn't matter.</p>
  <p><b>Richard Feynman</b> <em>— letter, 1966</em></p>
</blockquote>

Pull quote — .c-quote #

Used by the content block when is quote is toggled on.

A tinted box with a 4px accent rule on the leading edge. First and last children lose their outer margins.

  • Lists keep their indent inside a quote
  • Unlike lists in the body at wide viewports
Markup
<div class="c-quote">
  <p>A tinted box with a 4px accent rule on the leading edge. First and last
  children lose their outer margins.</p>
  <ul>
    <li>Lists keep their indent inside a quote</li>
    <li>Unlike lists in the body at wide viewports</li>
  </ul>
</div>

Well — .c-well and .update #

.c-well extends .update: same tinted background and angled clip-path, but padded rather than bled to the article edges. .update breaks out by $article-padding on both sides.

Looking for something?

A well groups an aside from the surrounding copy.

Update: full-bleed variant, angled along its bottom edge.

Markup
<div class="c-well">
  <h2 class="h4 is-tight">Looking for something?</h2>
  <p>A well groups an aside from the surrounding copy.</p>
</div>
<div class="update">
  <p><strong>Update:</strong> full-bleed variant, angled along its bottom edge.</p>
</div>

Lists #

  • Default unordered list
  • Indented below 37.5rem, flush above it
  • .list-unstyled — no marker, no indent
  • Second item
Markup
<ul>
  <li>Default unordered list</li>
  <li>Indented below 37.5rem, flush above it</li>
</ul>
<ul class="list-unstyled" role="list">
  <li>.list-unstyled — no marker, no indent</li>
  <li>Second item</li>
</ul>
<ul class="list-inline" role="list">
  <li class="list-inline-item"><a href="#sg-lists">.list-inline-item</a></li>
  <li class="list-inline-item"><a href="#sg-lists">Second</a></li>
  <li class="list-inline-item"><a href="#sg-lists">Third</a></li>
</ul>

Buttons #

A trimmed-down Bootstrap 5 button. .btn-alt is the site button — it maps onto the --btn-* theme tokens and therefore flips with the theme. .btn-primary is stock Bootstrap blue and does not. A bare .btn is transparent with --text-color text, so it stays legible in both themes but carries no affordance of its own — pair it with a modifier.

Markup
<p>
  <button type="button" class="btn btn-alt">.btn-alt</button>
  <button type="button" class="btn btn-primary">.btn-primary</button>
  <button type="button" class="btn">.btn (bare)</button>
  <button type="button" class="btn btn-alt" disabled>Disabled</button>
</p>

Search form #

The pattern used by site/snippets/search/: .input-group welds the control to the button by zeroing the facing border radii.

Markup
<form class="form form--search" role="search" action="#sg-search-form" method="get">
  <div class="input-group">
    <label class="sr-only" for="sg-q">Search</label>
    <input class="form-control" type="search" id="sg-q" name="q" placeholder="Search the archive…">
    <button class="btn btn-alt" type="submit">Search</button>
  </div>
  <div class="form-check">
    <input class="form-check-input" type="checkbox" id="sg-exact" name="exact">
    <label for="sg-exact">Exact match</label>
  </div>
</form>

Tables #

Bootstrap 5 table classes. caption-side is bottom globally; add .caption-top to flip it. .table-numeric right-aligns every cell after the first, and .row-header-normal unbolds [scope="row"]. Striping comes from --table-stripe-bg, a theme token, so it follows the theme toggle; tr.stripe stripes an individual row.

.table .table-sm .table-striped .table-numeric
YearPostsWords
20244218,300
20253715,120
2026197,940
Markup
<div class="table-responsive">
  <table class="table table-striped table-numeric row-header-normal table-sm">
    <caption>.table .table-sm .table-striped .table-numeric</caption>
    <thead>
      <tr><th scope="col">Year</th><th scope="col">Posts</th><th scope="col">Words</th></tr>
    </thead>
    <tbody>
      <tr><th scope="row">2024</th><td>42</td><td>18,300</td></tr>
      <tr><th scope="row">2025</th><td>37</td><td>15,120</td></tr>
      <tr class="stripe"><th scope="row">2026</th><td>19</td><td>7,940</td></tr>
    </tbody>
  </table>
</div>

Post list — .posts__list__item #

The archive and search row. Below md it is a single column with the meta reordered above the title; above it, title and excerpt take 75% and the meta block 25%. Only the first category link is shown — the rest are hidden with display: none.

Markup
<ul class="posts__list list-unstyled" role="list">
  <li class="posts__list__item">
    <h3 class="post__title"><a href="#sg-post-list">A representative post title</a></h3>
    <div class="post__meta">
      <span class="post__meta--date">12 Mar 2026</span>
      <span class="post__meta--category"><a href="#sg-post-list">thoughts</a></span>
    </div>
    <div class="post__excerpt"><p>The excerpt sits below both, at
    <code>$font-size-sm</code>.</p></div>
  </li>
  <li class="posts__list__item">
    <h3 class="post__title"><a href="#sg-post-list">A second row</a></h3>
    <div class="post__meta">
      <span class="post__meta--date">28 Feb 2026</span>
      <span class="post__meta--category"><a href="#sg-post-list">meta</a></span>
    </div>
    <div class="post__excerpt is-inline"><p>With <code>.is-inline</code>, so the
    paragraph runs inline.</p></div>
  </li>
</ul>

Category tab — .u-category__tab #

A standalone tab used on archive listings. Note it takes the light theme colour as its background, whereas .article__category a and .post__meta--category a use --title-meta-color.

Markup
<p><a class="u-category__tab" href="#sg-category-tab">thoughts</a></p>

Media object — .c-media #

The shared layout primitive behind the bookmark, travel and pinboard lists. .o-grid is the flex container; .c-media--meta and .c-media--description set column widths at md/xl. .u-location plus a data-location attribute is what makes the whole card clickable (assets/js/main.js), which is why the body gets the hover tint rather than the link.

Markup
<ul class="o-grid" role="list">
  <li class="c-media c-media--pinboard u-location" data-location="#sg-media-object">
    <div class="c-media__body">
      <div class="c-pinboard__title h3">
        <a class="c-pinboard__link" href="#sg-media-object">A linked article title</a>
      </div>
      <div class="c-pinboard__desc"><p>The description is hidden below the
      <code>md</code> breakpoint.</p></div>
      <div class="c-pinboard__title__meta"><span class="sr-only">Tagged with: </span>css / archive</div>
    </div>
  </li>
  <li class="c-media c-media--pinboard u-location" data-location="#sg-media-object">
    <div class="c-media__body">
      <div class="c-pinboard__title h3">
        <a class="c-pinboard__link" href="#sg-media-object">A second entry</a>
      </div>
      <div class="c-pinboard__desc"><p>Hover or focus tints the whole body.</p></div>
      <div class="c-pinboard__title__meta">meta</div>
    </div>
  </li>
</ul>

Pagination — .paging #

Markup
<ul class="paging list-inline" role="list">
  <li class="paging--item list-inline-item"><a href="#sg-pagination">← Newer</a></li>
  <li class="paging--item list-inline-item">Page 3 of 12</li>
  <li class="paging--item list-inline-item"><a href="#sg-pagination">Older →</a></li>
</ul>

Rules #

.u-rule--short is driven by --rule-width, --rule-length, --rule-yOffset and --rule-xOffset, and by default hangs 5rem into the left margin.

Markup
<hr>
<hr class="u-rule">
<hr class="u-rule--short" style="--rule-xOffset: 0">
<p><a class="readmore" href="#sg-rules">A .readmore link</a></p>

Content blocks

styles.scss (Article blocks) · styles-deferred.scss · site/snippets/blocks/

What the Kirby block fieldsets render into: figures, photosets, media embeds, code and the polaroid frame.

Figure and caption #

Figures are capped at the 65ch body measure. .wide breaks out progressively — a fifth of $article-figure-wide at sm, half at lg, the full -10rem at xl. Captions are centred until they wrap onto a second line, then left-aligned.

A default figcaption
.wide — breaks out of the measure on larger screens

.legend — the same treatment, without a figure

Markup
<figure>
  <img class="sg-placeholder" src="https://davidroessli.com/assets/img/placeholders/styleguide-placeholder.svg" alt="" width="1200" height="675">
  <figcaption>A default figcaption</figcaption>
</figure>
<figure class="wide">
  <img class="sg-placeholder" src="https://davidroessli.com/assets/img/placeholders/styleguide-placeholder.svg" alt="" width="1200" height="675">
  <figcaption>.wide — breaks out of the measure on larger screens</figcaption>
</figure>
<p class="legend">.legend — the same treatment, without a figure</p>

Photoset — .c-photoset #

A CSS grid driven by --cols. .col-2/.col-3/.col-4 set the column count; below 48rem everything collapses to two columns and below 30rem to one, unless .col-2--min keeps two. .c-photoset--fixheight switches to equal-height, flex-grown rows.

Markup
<div class="c-photoset col-3">
  <figure class="c-photoset__image"><img class="sg-placeholder" src="https://davidroessli.com/assets/img/placeholders/styleguide-placeholder.svg" alt="" width="600" height="400"></figure>
  <figure class="c-photoset__image"><img class="sg-placeholder" src="https://davidroessli.com/assets/img/placeholders/styleguide-placeholder.svg" alt="" width="600" height="400"></figure>
  <figure class="c-photoset__image"><img class="sg-placeholder" src="https://davidroessli.com/assets/img/placeholders/styleguide-placeholder.svg" alt="" width="600" height="400"></figure>
</div>

Inline images — .img__inline #

Float helpers for images inside body copy. Width modifiers are --16, --25, --33, --50 and --100. .img__inline-sm--right stays full-width on phones and only starts floating at sm. At xl, .is-breakout pushes the image four gutters into the margin and .is-rotate-5 tilts it.

Body copy wraps around a right-floated inline image at one third width. The float is cleared by the next block-level element, so long captions and short paragraphs need watching.

A second paragraph, still wrapping. Add .is-breakout to pull the image out of the measure on wide screens.

Markup
<div class="img__inline img__inline--right img__inline--33">
  <img class="sg-placeholder" src="https://davidroessli.com/assets/img/placeholders/styleguide-placeholder.svg" alt="" width="400" height="300">
</div>
<p>Body copy wraps around a right-floated inline image at one third width. The
float is cleared by the next block-level element, so long captions and short
paragraphs need watching.</p>
<p>A second paragraph, still wrapping. Add <code>.is-breakout</code> to pull the
image out of the measure on wide screens.</p>
<div class="clearfix"></div>

Video wrapper — .c-video #

Tinted, full-bleed on phones, and optionally .wide or .bg--black. .c-video--vertical is the portrait variant (half width, dark backing). The legacy div.full.pix / div.full.video selectors from pre-2019 posts extend this same rule.

16 × 9 embed slot
A caption below the embed
Markup
<div class="c-video">
  <div class="format--16by9 sg-embed-stub">16 × 9 embed slot</div>
  <figcaption>A caption below the embed</figcaption>
</div>

Audio — .c-audio #

.c-audio__control is 100% wide
Markup
<figure class="c-audio">
  <audio class="c-audio__control" controls></audio>
  <figcaption>.c-audio__control is 100% wide</figcaption>
</figure>

Code block — .c-code #

Always black-on-grey regardless of theme; only the text colour changes in dark mode. Note that inline <code> uses the body font, while <pre> and <kbd> use $mono-font-family.

.c-photoset {
  --cols: 2;
  display: grid;
  grid-template-columns: repeat(var(--cols), 1fr);
}
Markup
<pre class="c-code"><kbd>.c-photoset {
  --cols: 2;
  display: grid;
  grid-template-columns: repeat(var(--cols), 1fr);
}</kbd></pre>

Polaroid #

From styles-deferred.scss. Sized by --polaroid-size (half of it), rotated by --twist-it, corners by --corners. When combined with .has-linktip the tip is always visible and sits below the frame as a handwritten-style caption.

A polaroid caption
Markup
<is-polaroid class="polaroid has-linktip">
  <figure class="image vignette-colorful">
        <figure class="polaroid_image"><img alt="" src="https://davidroessli.com/assets/img/placeholders/styleguide-placeholder.svg"></figure>
    <div class="frame" data-comment="Yes, I know, this is ugly"></div>
    <figcaption class="linktip">A polaroid caption</figcaption>
  </figure>
</is-polaroid>

Image utilities #

Rounding and shadow helpers applied to the wrapper or the image itself. .u-rounded-icon is the iOS app-icon squircle approximation; .u-rounded-iphone* match device corner ratios.

.box--shadow, .box--wide and .format--16by9 round out the set.

Markup
<div class="c-row">
  <span class="u-shadow"><img class="sg-thumb" src="https://davidroessli.com/assets/img/placeholders/styleguide-placeholder.svg" alt="" width="120" height="120"></span>
  <span class="u-rounded"><img class="sg-thumb" src="https://davidroessli.com/assets/img/placeholders/styleguide-placeholder.svg" alt="" width="120" height="120"></span>
  <span class="u-rounded-icon"><img class="sg-thumb" src="https://davidroessli.com/assets/img/placeholders/styleguide-placeholder.svg" alt="" width="120" height="120"></span>
  <span class="u-rounded-iphone-portrait"><img class="sg-thumb" src="https://davidroessli.com/assets/img/placeholders/styleguide-placeholder.svg" alt="" width="120" height="120"></span>
</div>
<p><code>.box--shadow</code>, <code>.box--wide</code> and
<code>.format--16by9</code> round out the set.</p>

Utilities & accessibility

styles.scss (Utilities, Helper classes) · _mixins.scss

Text alignment

ClassEffectDeclared in
.text-start text-align: start styles
.text-end text-align: end styles
.text-center text-align: center styles
.text-vertical writing-mode: vertical-rl styles
.small / .text-sm font-size: 80% styles

Link resets

ClassEffectDeclared in
.plain no border-image, no hover background, pointer cursor styles
.no-underline no border-image, keeps the hover background styles
.underline real text-decoration with a 3px offset styles

Spacing & rules

ClassEffectDeclared in
.is-tight margin-block-end: 0 on headings, p, lists, blockquote styles
.u-rule 3px dotted rule with generous surrounding space styles
.u-rule--short short accent rule, tunable via --rule-* properties styles

Images & boxes

ClassEffectDeclared in
.u-shadow drop shadow on a descendant img styles
.u-rounded 1rem radius styles
.u-rounded-icon 22.5% radius + hairline border styles
.u-rounded-iphone / -landscape 4.7%/10% radius styles
.u-rounded-iphone-portrait 10%/4.7% radius styles
.u-still opts an image out of the lightbox hover transform styles
.box--shadow white padded card with a shadow styles
.box--wide max-width: 100% styles
.format--16by9 25vh tall, object-fit: cover, full width styles

Visibility

ClassEffectDeclared in
.hidden display: none — wins via the utilities layer, no !important styles
.visually-hidden clipped but readable by assistive tech styles
.visually-hidden-focusable as above, revealed on focus styles
.sr-only the mixin version of the same idea styles + _mixins
.sr-only-focusable reveals .sr-only on focus styles + _mixins
.hide--sm hidden below 30rem styles-deferred
.print-only hidden on screen, inline-block in print styles-deferred
.print-none hidden in print styles-deferred

Layout

ClassEffectDeclared in
.clearfix contains floats styles-deferred
.c-row flex row, wrapping, 1rem gap styles
.o-grid the flex grid behind media lists styles
.col-2 / .col-3 / .col-4 column count for .c-photoset styles
.col-2--min keeps two columns below 30rem styles
.wide negative-margin breakout for figures, video and divs styles
.no-clip removes a section's clip-path and fill styles
.nopic article variant without a hero styles
.no-pad reduces the two-column body's top padding styles

Screen-reader-only text #

There are four overlapping implementations in the codebase: .sr-only, .visually-hidden, .visuallyhidden and the .skip-main off-canvas trick. Prefer .sr-only in new markup — it is the one used by the article and bookmark snippets.

Posted in the category thoughts — the parenthetical is only announced, never seen.

Markup
<p>Posted in<span class="sr-only"> the category</span> thoughts —
the parenthetical is only announced, never seen.</p>

Focus handling #

Outlines are suppressed for mouse users via :focus:not(:focus-visible) and preserved for keyboard users. Anything focused inside main gets 80px of scroll margin above and 100px below so sticky chrome never covers it, and [id] targets get 2rem of scroll margin for anchor jumps.

Tab through

Theme toggle #

The banner control writes data-theme="light" or "dark" onto <html>, and the stylesheet maps that to color-scheme. Every theme token is declared once with both values via light-dark(), so there is no parallel dark copy to maintain. Without JavaScript no attribute is set, and color-scheme: light dark on :root means light-dark() follows the system preference on its own. In dark mode all <img> elements drop to 75% opacity and return to full opacity on hover — that one still needs a selector, because light-dark() only takes colours.

The live control is in the banner at the top of this page.