Recipes / patterns from core/macros.css. Macros answer
"what does this element do / look like?" — distinct from layout
primitives, which answer "where do my children go?".
Layer: slashed.macros (between slashed.layout and
slashed.utilities). Macros may compose with primitives and components,
but a single-property utility still wins on the same selector.
All tokens listed below live in core/tokens.macros.css and ship
in the optimal bundle.
Long-form text column with automatic vertical rhythm.
<div class="sf-prose">
<h2>Heading</h2>
<p>Paragraph one.</p>
<p>Paragraph two — automatically spaced.</p>
<ul><li>Bullet</li><li>List</li></ul>
</div>Styles direct children with margin-block-start: var(--sf-prose-paragraph),
restores native list bullets, lays out figure, figcaption, table,
video. Inside a .sf-prose, drop .sf-not-prose on a region to opt out.
Tokens:
| Token | Default | What it controls |
|---|---|---|
--sf-prose-paragraph |
var(--sf-content-gap) |
gap between block children |
Resets .sf-prose styling inside the marked subtree (block margins,
list styling, figure margins, image rounding). Useful for embedded
widgets in long-form text.
<div class="sf-prose">
<p>Article body.</p>
<div class="sf-not-prose">
<!-- a card or widget that should not inherit prose rules -->
</div>
<p>More body.</p>
</div>Heydon Pickering's "lobotomized owl". Every flow child after the first
gets margin-block-start: var(--sf-flow-space).
<div class="sf-flow">
<p>One</p>
<p>Two</p> <!-- has top margin -->
<p>Three</p> <!-- has top margin -->
</div>Tokens:
| Token | Default | What it controls |
|---|---|---|
--sf-flow-space |
var(--sf-content-gap) |
distance between consecutive children |
Override per element: style="--sf-flow-space: 2rem".
Single-line ellipsis. The element must have a finite inline-size for the overflow to actually clip.
<div class="sf-truncate" style="max-inline-size: 20rem">
This very long sentence will be ellipsised after one line.
</div>The ellipsis is supplied by text-overflow: ellipsis; no token is needed.
Multi-line clamp with ellipsis. The fixed-count variants hardcode the
line count; -N reads --sf-line-clamp.
<p class="sf-line-clamp-2">Two-line clamp.</p>
<p class="sf-line-clamp-3">Three-line clamp.</p>
<p class="sf-line-clamp-N" style="--sf-line-clamp: 5">N-line clamp.</p>Tokens:
| Token | Default | What it controls |
|---|---|---|
--sf-line-clamp |
3 |
line count for .sf-line-clamp-N |
-webkit-line-clamp is a de-facto standard despite the prefix — every
modern engine implements it. The unprefixed line-clamp from CSS
Overflow 4 is set alongside for forward compatibility.
Forces flex children to share the tallest child's height.
<div class="sf-equal-height">
<div>Short</div>
<div>Two<br>lines</div> <!-- becomes 3-line tall -->
<div>Three<br>lines<br>here</div>
</div>Pairs naturally with grid layouts where rows already stretch. Use this when working in flex contexts.
Top + bottom mask gradient that fades content near the edges of a vertically scrolling container. Pure CSS — no scroll listener.
<div class="sf-scroll-shadow" style="block-size: 12rem">
<p>Lots of content…</p>
</div>Tokens:
| Token | Default | What it controls |
|---|---|---|
--sf-scroll-shadow-size |
2rem |
fade depth on both edges |
Pairs nicely with overflow-y'd lists, code blocks, or tall card bodies.
Vertical scroll-snap container. Each direct child snaps to start.
For horizontal snap, use the .sf-reel layout primitive.
<div class="sf-scroll-snap" style="block-size: 100dvh">
<section style="block-size: 100dvh">A</section>
<section style="block-size: 100dvh">B</section>
<section style="block-size: 100dvh">C</section>
</div>Gradient mask fade for overflowing content. Pure alpha mask — respects
the element's actual background. Reads --sf-mask-scrim-start /
--sf-mask-scrim-end for fade depth.
All directions are physical (not logical): --right always fades
the physical right edge regardless of writing direction. For logical
inline-end fading in RTL layouts, add a :dir(rtl) override that
swaps the gradient direction. Modifier classes work standalone and
target a specific edge or axis:
| Class | Fades |
|---|---|
.sf-overflow-fade |
right edge (default) |
.sf-overflow-fade--right |
right edge (explicit) |
.sf-overflow-fade--left |
left edge |
.sf-overflow-fade--top |
top edge |
.sf-overflow-fade--bottom |
bottom edge |
.sf-overflow-fade--block |
top and bottom edges |
.sf-overflow-fade--inline |
left and right edges |
<!-- Single edge -->
<div class="sf-overflow-fade" style="white-space: nowrap">
<span class="tag">…</span>
<span class="tag">…</span>
</div>
<!-- Both inline edges (left + right) -->
<div class="sf-overflow-fade--inline" style="white-space: nowrap">…</div>
<!-- Bottom-only (e.g. truncated prose preview) -->
<div class="sf-overflow-fade--bottom" style="max-height: 6rem">…</div>Suppresses the WebKit/Android grey tap-highlight overlay on
interactive elements where it conflicts with the framework's own
:active / hover treatment.
<a class="sf-no-tap-highlight" href="…">…</a>Just sets -webkit-tap-highlight-color: transparent. No tokens.
Skips rendering (layout + paint) for offscreen content until it scrolls near the viewport — a large initial-render win on long pages (product grids, long articles).
<section class="sf-render-lazy">…repeated long-page section…</section>
<!-- Override the reserved placeholder size -->
<section class="sf-render-lazy" style="--sf-content-intrinsic-size: 800px">…</section>Tokens:
| Token | Default | What it controls |
|---|---|---|
--sf-content-intrinsic-size |
500px |
placeholder block size fed to contain-intrinsic-size |
Sets content-visibility: auto plus contain-intrinsic-size: auto var(--sf-content-intrinsic-size). The auto keyword caches each section's
last-rendered size; the token reserves space before first render so the scrollbar
and scroll position stay stable. Unsupported engines (Safari < 18) ignore both
declarations and render normally.
Deliberately not paired with
will-change— it pre-creates compositing layers and usually hurts performance when applied broadly; set it from JS only while an element is actively animating.
Fixed-width digits so numbers align in vertical columns (price lists, totals, invoices, dashboards).
<table class="sf-tabular-nums">…numeric columns…</table>Tokens:
| Token | Default | What it controls |
|---|---|---|
--sf-font-numeric |
tabular-nums |
figure style (core/tokens.css) |
Sets font-variant-numeric: var(--sf-font-numeric, tabular-nums). The
same token is applied to <input type="number"> in optional/forms.css.
Universal browser support.
Applies filter: drop-shadow(...) — unlike box-shadow, this follows the
actual alpha shape of the element (PNG cutouts, SVG icons, transparent
logos) instead of hugging the bounding box.
<img class="sf-drop-shadow-m" src="logo.svg" alt="">
<svg class="sf-drop-shadow-xl" ...>...</svg>Tokens:
| Token | What it controls |
|---|---|
--sf-drop-shadow-xs / -s / -m / -l / -xl |
drop-shadow value consumed 1:1 by the matching class (core/tokens.css) |
--sf-text-shadow-xs / -s / -m / -l / -xl mirror the same five-step
scale for text-shadow (no dedicated utility class — apply the token
directly via text-shadow: var(--sf-text-shadow-l)), matching the
box-shadow ramp's xs..2xl rhythm at the small/large ends.
Contextual background + auto-contrast text color. Apply to any element to give it a filled surface with accessible foreground text.
.sf-surface (no modifier) takes any color through --sf-surface-color
(default: --sf-color-base) — including palette shades — and derives the
background, an auto-contrast foreground (the same lightness-flip used by
--sf-color-text--on-*), and the full contextual token set:
<!-- a palette tint surface -->
<section class="sf-surface" style="--sf-surface-color: var(--sf-color-primary-100)">
Text, headings, links and borders re-derive automatically.
</section>
<!-- any arbitrary color works -->
<aside class="sf-surface" style="--sf-surface-color: oklch(0.35 0.09 200)">…</aside>--sf-surface-color inherits: a nested .sf-surface picks up the outer
surface's color unless it sets its own. The derivation requires relative
color syntax; outside the @supports gate only the background applies.
10 precomputed variants: primary, secondary, tertiary, action,
neutral, inverse, success, warning, info, danger.
<div class="sf-surface--primary">White text on primary bg</div>
<div class="sf-surface--danger">White text on danger bg</div>
<div class="sf-surface--neutral">Auto-contrast text on neutral bg</div>Each variant sets background to the resolved color token
(--sf-color-{name}) and color to the matching on-color token
(--sf-color-text--on-{name}). No extra tokens needed.
Both forms rebind the same contextual token set so descendants adapt with no extra classes. To make any BEM component a conforming surface, copy the contract (shown here seeded from a custom foreground/background pair):
@supports (color: oklch(from red l c h)) {
.my-component {
/* your component's background */
--my-bg: var(--sf-color-primary);
background: var(--my-bg);
/* pick or derive the foreground for your background */
--my-fg: var(--sf-color-text--on-primary);
color: var(--my-fg);
--sf-color-text: var(--my-fg);
--sf-color-heading: var(--my-fg);
--sf-color-link: oklch(from var(--my-fg) l calc(c + 0.08) h);
--sf-color-link--hover: var(--my-fg);
--sf-color-link--underline: oklch(from var(--my-fg) l c h / 0.5);
--sf-color-text--subtle: oklch(from var(--my-fg) l c h / 0.70);
--sf-color-text--placeholder: oklch(from var(--my-fg) l c h / 0.45);
--sf-color-text--disabled: oklch(from var(--my-fg) l c h / 0.30);
--sf-color-border: oklch(from var(--my-fg) l c h / 0.20);
--sf-color-border--subtle: oklch(from var(--my-fg) l c h / 0.12);
--sf-color-border--strong: oklch(from var(--my-fg) l c h / 0.35);
--sf-shadow-color: oklch(from var(--my-bg) 0.15 c h);
}
}In most cases the simpler route is to set --sf-surface-color on
.sf-surface and let the framework do this for you.
Fills text with a gradient (default --sf-gradient-primary).
<h2 class="sf-text-gradient">Gradient headline</h2>
<!-- Override per-instance -->
<h2 class="sf-text-gradient" style="background-image: var(--sf-gradient-secondary)">
Secondary gradient
</h2>background-clip: text and color: transparent are applied unconditionally (no
@supports gate). The unprefixed form is used — it is supported at the framework
floor (Safari 18.0+, Chrome 125+, Firefox 129+). Browsers that don't clip
backgrounds to text render the text invisible — an accepted consequence of the
support floor.
Known limitation: selecting gradient text reveals the clipping boundary (text appears to lose colour during selection) in most browsers.
Adds an external-link indicator glyph after the link text via ::after,
plus a screen-reader-only accessible name for that glyph using the CSS
alt-text syntax (content: <value> / <string>) — assistive tech reads it
appended after the link's own text; sighted users only see the glyph.
<a href="https://example.com" class="sf-link-external">Example</a>Tokens:
| Token | Default | What it controls |
|---|---|---|
--sf-link-external-marker |
" \2197" (arrow with leading space) |
glyph appended after link text |
--sf-link-external-label |
"opens in a new window or external site" |
accessible name read by screen readers for the glyph |
Disable globally (both the glyph and its accessible name):
:root {
--sf-link-external-marker: "";
--sf-link-external-label: "";
}Localise the announcement by overriding --sf-link-external-label inside a
:lang() block or a locale-scoped selector.
.sf-link-external is opt-in — you add the class per link. To apply the
same treatment automatically to every cross-origin link, write your own
rule keyed to your site's own host (CSS selectors can't read a custom
property, so the host has to be a literal string) and exclude links that
wrap an image, since those carry their own accessible name:
a[href^="http"]:not([href*="example.com"]):not(:has(img, svg, picture))::after {
content: var(--sf-link-external-marker) / var(--sf-link-external-label);
display: inline-block;
font-size: 0.85em;
text-decoration: none;
}Swap example.com for your own domain. Page builders and CMS integrations
(e.g. the WordPress plugin) can generate this rule with the site's real
host injected server-side.
Opt-in link underline affordances. They don't change link colour
(that stays the auto-contrast --sf-color-link); they only toggle the
underline.
<a href="…" class="sf-link--subtle">Underline appears on hover/focus</a>
<a href="…" class="sf-link--reverse">Underlined at rest, clears on hover</a>| Class | Resting state | Hover / focus |
|---|---|---|
.sf-link--subtle |
no underline | underline (currentcolor) |
.sf-link--reverse |
underline | no underline |
.sf-link--subtle suits dense link lists (nav, footers) where a
permanent underline is noisy; the hover underline preserves the
affordance at the moment of interaction.
The base a:link underline geometry is tokenised (added for parity with
the colour tokens):
| Token | Default | What it controls |
|---|---|---|
--sf-link-underline-offset |
0.15em |
distance from the text baseline |
--sf-link-underline-thickness |
auto |
underline stroke width (auto = font metrics) |
Darkening overlay for text placed over a background image, so the text
clears contrast without dimming the whole picture. Apply to a
positioned wrapper holding the image + text; the scrim paints as a
::before gradient between them (the macro sets position: relative
and isolation: isolate itself).
<div class="sf-scrim sf-scrim--bottom" style="position:relative">
<img src="hero.jpg" alt="" style="display:block; inline-size:100%">
<div class="sf-scrim__content" style="position:absolute; inset-block-end:0">
<h2>Legible headline</h2>
</div>
</div>Media children (img, picture, video, svg, canvas) are left in
the background layer so the scrim darkens them; only non-media children
are lifted above the scrim. Position your content over the image with
position: absolute (as above) or use a CSS background-image on the
wrapper instead of an <img> child.
Media background + overlay + stacked content, with no manual
z-index: compose with the .sf-bg-layer layout primitive
instead of a plain <img> — it auto-fills the parent (position: absolute; inset: 0) and already composes under .sf-scrim by design,
so img/video background, gradient, and content stack correctly with
zero extra positioning:
<div class="sf-scrim sf-scrim--bottom">
<img class="sf-bg-layer" src="hero.jpg" alt="">
<div class="sf-scrim__content">
<h2>Media background, scrim, and content — no z-index to manage</h2>
</div>
</div>This is the framework's answer to "background media + overlay + stacked
content" — no dedicated macro needed on top of .sf-bg-layer + .sf-scrim.
Variants:
| Class | Effect |
|---|---|
.sf-scrim--bottom |
gradient darkest at the bottom (default — text at bottom) |
.sf-scrim--top |
gradient darkest at the top |
.sf-scrim--full |
even wash over the whole image |
Tokens:
| Token | Default | What it controls |
|---|---|---|
--sf-scrim-color |
oklch(0 0 0 / 0.55) |
the dark stop |
--sf-scrim-direction |
to top |
gradient direction |
--sf-scrim-gradient |
linear-gradient(var(--sf-scrim-direction), var(--sf-scrim-color), transparent) |
the whole composed gradient (override for multi-stop / radial) |
The lift selector is .sf-scrim > :not(img, picture, video, svg, canvas) { z-index: 1 }.
A reusable, named background surface preset. Where .sf-surface sets a
solid colour and .sf-scrim adds a single gradient overlay, .sf-surface-bg
bundles a full background into one class you can name once and reuse: base
colour fallback + image/gradient/pattern + sizing + an optional overlay
layered above the image + an optional animation.
The class itself is inert — it only composes the --sf-surface-bg-* tokens.
Define a preset by setting those tokens on a scope, then apply the class:
.hero-surface {
--sf-surface-bg-image: url("/hero.avif");
--sf-surface-bg-overlay: var(--sf-scrim-gradient); /* reuse the scrim */
--sf-surface-bg-animation: sf-pan 40s linear infinite; /* optional */
}<section class="hero-surface sf-surface-bg">…</section>The overlay is the first background-image layer, so it paints above
the image (use it for a scrim/tint over a photo). Builds on the existing
scrim + gradient tokens rather than new infrastructure; for a blurred backdrop
compose .sf-scrim or a filter on top.
Tokens:
| Token | Default | What it controls |
|---|---|---|
--sf-surface-bg-color |
transparent |
base colour fallback (below the image) |
--sf-surface-bg-image |
none |
the image / gradient / pattern layer |
--sf-surface-bg-overlay |
none |
overlay layered above the image (e.g. a scrim) |
--sf-surface-bg-size |
cover |
background-size |
--sf-surface-bg-position |
center |
background-position |
--sf-surface-bg-repeat |
no-repeat |
background-repeat |
--sf-surface-bg-attachment |
scroll |
background-attachment |
--sf-surface-bg-animation |
none |
optional animation shorthand |
Lighter-weight alternative to .sf-scrim: protects text legibility over
a busy image without a darkening layer, using a soft shadow halo
behind the glyphs. Apply directly to the text element.
<h2 class="sf-text-protect">Readable over a photo</h2>Tokens:
| Token | Default | What it controls |
|---|---|---|
--sf-scrim-text-shadow |
0 1px 3px oklch(0 0 0 / 0.6) |
the protective text shadow |
Scroll-driven entrance animations. Elements animate into view as they enter the viewport.
6 variants: fade, fade-up, fade-down, fade-left, fade-right,
scale-up.
<div class="sf-entrance--fade-up">Fades in while sliding up</div>
<div class="sf-entrance--scale-up">Scales from 95% to 100%</div>How it works: Uses animation-timeline: view() where supported
(Chrome/Edge 115+). In browsers without scroll-driven animation support
(Firefox, which keeps it behind a flag, and Safari), the class falls back
to a one-shot time-driven animation at --sf-duration-slow.
Tokens:
| Token | Default | What it controls |
|---|---|---|
--sf-scroll-timeline-range-start |
entry 0% |
when the animation begins |
--sf-scroll-timeline-range-end |
cover 30% |
when the animation completes |
All entrance classes are gated by prefers-reduced-motion: no-preference;
when the user opts out of motion the animations are inert (no movement).
Scroll-driven exit animations — the symmetric counterpart of
.sf-entrance--*. Elements animate out as they leave the viewport.
6 variants: fade, fade-up, fade-down, fade-left, fade-right,
scale-down.
<div class="sf-exit--fade-up">Fades out while sliding up as it leaves</div>
<div class="sf-exit--scale-down">Scales from 100% to 92% on exit</div>How it works: Uses animation-timeline: view() where supported
(Chrome/Edge 115+). Unlike .sf-entrance--*, there is no time-driven
fallback: animation-name only applies inside
@supports (animation-timeline: view()). An unconditional one-shot exit
animation would fade the element out on load and leave it hidden forever
in engines without scroll-driven animation support — so those engines
just render the element normally, visible and static.
Tokens:
| Token | Default | What it controls |
|---|---|---|
--sf-scroll-timeline-range-exit-start |
cover 70% |
when the exit animation begins |
--sf-scroll-timeline-range-exit-end |
exit 100% |
when the exit animation completes |
All exit classes are gated by prefers-reduced-motion: no-preference;
when the user opts out of motion the animations are inert (no movement).
Lives in core/motion.css, layer slashed.motion.
Choreography helper: put it on a parent and every direct child receives
an incrementing animation-delay, so a group of time-based entrance
animations plays in sequence.
<ul class="sf-stagger">
<li class="sf-fade-in">First</li>
<li class="sf-fade-in">Second</li>
<li class="sf-fade-in">Third</li>
</ul>.sf-stagger sets only the delay — each child still needs its own
time-based entrance animation (the fade / slide-in looping classes in
motion.md). A child without one carries an inert delay (a
no-op), so animating only some children needs no opt-out on the rest.
Tokens:
| Token | Default | What it controls |
|---|---|---|
--sf-stagger-step |
75ms |
per-item delay increment |
Each child's delay is index × --sf-stagger-step × --sf-motion-scale. Where
sibling-index() is supported the index is unbounded; otherwise an 8-step
:nth-child ramp (covering a 4-column grid's first two rows) plateaus so
arbitrarily long lists still animate.
Best paired with the time-based fade / slide-in looping classes (see
motion.md), which stagger consistently everywhere. On the
scroll-driven path (.sf-entrance--*/.sf-exit--* under
animation-timeline: view()) the rhythm is animation-range, not
animation-delay, so stagger has no effect there — though .sf-entrance--*
does stagger in its time-based fallback on engines without view(), while
.sf-exit--* has no such fallback. Gated by
prefers-reduced-motion: no-preference.
Lives in core/motion.css, layer slashed.motion.