Skip to content

Latest commit

 

History

History
712 lines (525 loc) · 22.9 KB

File metadata and controls

712 lines (525 loc) · 22.9 KB

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-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-render-lazy

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.


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


.sf-link-external

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.


.sf-link--subtle, .sf-link--reverse

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.