Macros

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.


.sf-prose

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

.sf-not-prose

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>

.sf-flow

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".


.sf-truncate

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.


.sf-line-clamp-2, .sf-line-clamp-3, .sf-line-clamp-N

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.


.sf-equal-height

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.


.sf-aspect

Generic aspect-ratio container.

<div class="sf-aspect" style="--sf-aspect: 4 / 3"></div>

Tokens:

Token Default What it controls
--sf-aspect 16 / 9 the ratio

Distinct from the .sf-frame layout primitive, which is media-specific (includes object-fit: cover for child img/video). .sf-aspect is content-agnostic.


.sf-scroll-shadow

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.


.sf-scroll-snap

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>

.sf-overflow-fade

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>

.sf-no-tap-highlight

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.


.sf-content-auto

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-content-auto">…repeated long-page section…</section>

<!-- Override the reserved placeholder size -->
<section class="sf-content-auto" 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.


.sf-tabular-nums

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.


.sf-drop-shadow-xs / .sf-drop-shadow-s / .sf-drop-shadow-m / .sf-drop-shadow-l / .sf-drop-shadow-xl

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.


.sf-surface and .sf-surface--*

Contextual background + auto-contrast text color. Apply to any element to give it a filled surface with accessible foreground text.

Generic surface: any color

.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.

Named variants

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.

Author your own surface

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.


.sf-text-gradient

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.

Automatic detection by domain

.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)

.sf-scrim

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 }.


.sf-surface-bg

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

.sf-text-protect

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

.sf-entrance--*

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).


.sf-exit--*

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.


.sf-stagger

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.


.sf-overlap and .sf-overlap-host

A recipe for one element intentionally overlapping the element before it (e.g. an avatar or image pulled up over the card below it), and the receiving container that reserves space for the intrusion.

<img class="sf-overlap" src="badge.png" alt="">
<article class="sf-card sf-overlap-host">
  Content starts below the intruding image automatically.
</article>

.sf-overlap variants (directional — standalone, not knob re-pointers, since each moves a different margin):

Class Effect
.sf-overlap pulls up over the previous element (default)
.sf-overlap--down pulls down over the next element

Tokens:

Token Default What it controls
--sf-overlap-pull var(--sf-space-xl) how far .sf-overlap pulls into the adjacent element

.sf-overlap-host’s block-start padding reads var(--sf-overlap-host-pad, var(--sf-overlap-pull)) directly in the CSS declaration rather than through a declared token, so it isn’t part of the token registry — set --sf-overlap-host-pad inline on an instance to compensate by something other than the default pull amount.

.sf-overlap-host sets isolation: isolate so the overlapping element’s raised z-index stays scoped to this container rather than fighting page-level stacking.