Styleguide
Posted in meta
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.
Surfaces
Text
Links
Tables
Forms & buttons
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.
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.
| Token | rem | px | Used 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
| Token | Value | Applied 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.
| Layer | What belongs here |
|---|---|
reset | Normalize and the H5BP defaults. Never site-specific. |
base | Theme tokens, typography, and bare-element defaults — html, body, img, button. |
vendor | Third-party CSS: lightbox, cocoen, the video embeds. |
components | Almost everything you will write. The default choice. |
utilities | Only 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
.widefigures break out atxl
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.
| Name | rem | px | What 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-3cubic-bezier(0.25, 0, 0.3, 1)--ease-out-5cubic-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
Posted in thoughts
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.
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.
meta Pinned post
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.
Aside links #
Populated at runtime by assets/js/sidelinks.js, which harvests
links from the post body. Empty in the markup; above xl it takes
the leftover column beside the 65ch measure.
Markup
<div class="aside-links">
<ul class="list-unstyled">
<li><a href="#sg-aside-links">A link harvested from the body</a></li>
<li><a href="#sg-aside-links">Another one</a></li>
</ul>
</div>
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
Markup
<h2 class="section__content__title">Section title <span class="section__content__title__meta">— with meta</span></h2>
Components
styles.scss (Typography, Lists, Sections, Buttons, Table)
Links #
The default anchor has no text-decoration. The "underline" is an
SVG border-image tinted with the light theme colour, which is why
.no-underline and .plain have to reset
border-style as well as border-image.
.plain additionally kills the hover background.
A default link in running text.
A .no-underline link, a .plain link and an .underline link.
.text-link applies the same treatment to a non-anchor.
Hover for a linktipTooltip text
Markup
<p>A <a href="#sg-links">default link</a> in running text.</p>
<p>A <a class="no-underline" href="#sg-links">.no-underline</a> link, a
<a class="plain" href="#sg-links">.plain</a> link and an
<a class="underline" href="#sg-links">.underline</a> link.</p>
<p><span class="text-link">.text-link</span> applies the same treatment to a
non-anchor.</p>
<p class="has-linktip"><a href="#sg-links">Hover for a linktip</a><span class="linktip">Tooltip text</span></p>
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><b></b> and <i><i></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 + 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.
| Year | Posts | Words |
|---|---|---|
| 2024 | 42 | 18,300 |
| 2025 | 37 | 15,120 |
| 2026 | 19 | 7,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.
-
A representative post title
The excerpt sits below both, at
$font-size-sm. -
A second row
With
.is-inline, so the paragraph runs inline.
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.
-
The description is hidden below the
mdbreakpoint. -
Hover or focus tints the whole body.
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.
.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.
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 #
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.
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
| Class | Effect | Declared 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
| Class | Effect | Declared 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
| Class | Effect | Declared 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
| Class | Effect | Declared 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
| Class | Effect | Declared 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
| Class | Effect | Declared 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 |
Skip links #
Three of them — to main, to nav and to the footer. They are clipped to
nothing with clip-path and expand to an 80px full-width bar on
focus, inverting the theme colours. Tab into the demo below to see it.
Markup
<div><a href="#sg-skip-links" class="text-assistive display-at-top-on-focus">Skip link (focus me)</a></div>
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.
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.
