SLASHED — Architecture
Layer order
Declared once in core/layers.css:
@layer
slashed.tokens,
slashed.reset,
slashed.base,
slashed.forms,
slashed.components,
slashed.layout,
slashed.macros,
slashed.utilities,
slashed.states,
slashed.themes,
slashed.motion,
slashed.accessibility,
slashed.print,
slashed.legacy,
slashed.overrides;
Priority increases left → right. Unlayered consumer CSS beats all layers. slashed.overrides ships empty. slashed.legacy is for backward-compatibility fallbacks and sits just below slashed.overrides so user overrides always win.
slashed.macros sits between layout and utilities. Macros may compose with primitives and components, but a single-property utility still wins on the same selector.
File structure
core/
layers.css @layer declaration only
tokens.css slashed.tokens
tokens.layout.css slashed.tokens (semantic layout tokens)
tokens.macros.css slashed.tokens (semantic macro tokens)
reset.css slashed.reset
base.css slashed.base
themes.css slashed.themes
layout.css slashed.layout
macros.css slashed.macros (recipes / patterns)
states.css slashed.states
motion.css slashed.motion
accessibility.css slashed.accessibility
print.css slashed.print
optional/
tokens.components.css slashed.tokens (component tokens for btn + card)
customize-example.css slashed.themes, slashed.overrides (copy-and-customise rebrand/config/overrides starter; not bundled)
forms.css slashed.forms (classless native form-control styling)
components.css slashed.components (btn + card — the only two components)
utilities.css slashed.utilities (curated subset active — heading/text-size/hover/list-reset/marker-colour/alternate-selection/sticky)
legacy.css slashed.legacy
optional/components.css and optional/tokens.components.css ship exactly two
components — .sf-btn and .sf-card, live since v0.7.0. These are the only
components SLASHED carries: the framework stays BEM-first and token-first and
does not maintain a broad component library. See docs/components.md
for the component reference.
optional/utilities.css ships a curated subset — the heading (.sf-h1–.sf-h6),
text-size (.sf-text-*), hover-transform (.sf-hover-*), list-reset,
marker-colour, alternate-selection, and sticky helpers are active; the rest stay
commented out. SLASHED is BEM-first by design, so it ships only this curated
subset of utility classes in 0.x.
Class taxonomy
Every shipped class falls into exactly one of seven categories. The category answers a single question for the consumer; this is the decision tree to use when adding a new class.
| # | Category | Question it answers | File | Layer |
|---|---|---|---|---|
| 1 | Layout primitives | “How are children arranged?” | core/layout.css |
slashed.layout |
| 2 | Macro-classes (recipes) | “What does this element DO / look like?” | core/macros.css |
slashed.macros |
| 3 | Animation primitives | “How does this element move?” | core/motion.css |
slashed.motion |
| 4 | State utilities (.sf-is-*) |
“What state is this element in?” | core/states.css |
slashed.states |
| 5 | A11y utilities & patterns | “How does this work for AT users?” | core/accessibility.css |
slashed.accessibility |
| 6 | Print utilities (.print-*) |
“How does this print?” | core/print.css |
slashed.print |
| 7 | Components (opt-in) | “Pre-built visual block.” | optional/components.css |
slashed.components |
Decision tree for adding a new class
1. Does it answer "where do my children go?"
YES → core/layout.css (slashed.layout)
NO → step 2
2. Does it modify an element's runtime state?
(hover, active, disabled, loading…)
YES → core/states.css (.sf-is-*/.sf-has-*) (slashed.states)
NO → step 3
3. Is it an a11y pattern or a11y utility?
(sr-only, sf-focus-parent, clickable-parent…)
YES → core/accessibility.css (slashed.accessibility)
NO → step 4
4. Is it a single-property animation trigger?
YES → core/motion.css (slashed.motion)
NO → step 5
5. Is it an opinionated visual component?
(button, card, alert…)
YES → optional/components.css (slashed.components)
NO → core/macros.css (slashed.macros) ← default
Macros are the default fallback for behavioural/visual recipes that aren’t layout, state, a11y, animation, or component.
Layout primitives vs macros
| Test | Primitive | Macro / recipe |
|---|---|---|
| Used as the only class on an element? | Often (<ul class="sf-cluster">) |
Rarely — usually composed (class="card sf-clickable-parent") |
| Defines arrangement of children? | Yes (display, gap, grid-template) | Sometimes, but secondary |
Has tokenised knobs (--sf-X-gap etc.)? |
Yes, central to the API | Sometimes — minimal |
| Touches pseudo-elements / states? | Rarely | Often (::after, :hover, :focus-within) |
| Container queries inside? | Often | Rarely |
| Sole responsibility | “Where do my children go?” | “What does this DO / look like?” |
Heuristic: a primitive is a noun (a stack, a grid); a macro is a verb/adjective (clickable, scrollable, prose).
Two conceptually-macro classes stay in layout.css in 0.x for compatibility:
.sf-content-grid/.sf-breakout/.sf-full-bleed— article-with-breakouts; layout-first because the rule defines a grid..sf-icon(and.sf-icon--xs..xl..boxed) — graphical primitive reached for as a single class.
Layers
slashed.tokens — custom properties only, :root only. No element rules. All values consumers might override are tokens. Spread across multiple files: tokens.css (core — brand/status/semantic/numeric palette/alpha tokens), tokens.layout.css (layout primitives), tokens.macros.css (macros), and tokens.components.css (optional component-level tokens).
slashed.reset — browser normalization. Minimal var() usage (only with hardcoded fallbacks for critical layout values like scroll-padding-top).
slashed.base — element defaults, all values via var(). A minimal,
readable foundation for flow and inline text — not a classless UI kit.
SLASHED is BEM-first: the token API is the product, and the base holds the
line at three tiers:
- Global base — flow/inline readability: headings,
p,a,code,pre,mark,hr,sub/sup,abbr,::selection. - Rich blocks (
table,blockquote,figure,dl) — styled only inside.sf-prose(a macro-class), never globally. - Interactive widgets (
dialog,details,progress,meter) — consumer/component territory (the opt-inslashed.componentslayer);corecarries reset-level normalization only.
Native form controls are out of base entirely — they live in the opt-in
slashed.forms layer.
slashed.forms — opt-in classless styling for native form controls
(input, select, textarea, button, checkbox/radio, fieldset,
label) from optional/forms.css. Element-level only, no classes. Reads
--sf-field-border-color so the .sf-is-* validation states in
slashed.states recolour fields. Skip the file entirely if you prefer full
BEM control.
slashed.components — UI blocks: .sf-btn and .sf-card, live since v0.7.0.
These are the only two components SLASHED ships — it stays BEM-first and does
not carry a broad component library. Every value goes through var(). Requires
tokens.components.css.
slashed.layout — layout primitives: .sf-stack, .sf-cluster, .sf-sidebar, .sf-cover, .sf-grid, .sf-container, .sf-content-grid, .sf-icon, etc. Layout tokens declared in tokens.layout.css, overridable per-instance via style="--sf-stack-gap: …".
slashed.macros — recipes / patterns: .sf-prose,
.sf-not-prose, .sf-flow, .sf-truncate, .sf-line-clamp-{2,3,N},
.sf-equal-height, .sf-aspect, .sf-scroll-shadow, .sf-scroll-snap,
.sf-overflow-fade, .sf-no-tap-highlight. Tokens for these classes
live in core/tokens.macros.css.
slashed.utilities — single-purpose helpers. SLASHED is BEM-first and
ships only a curated subset in 0.x (heading, text-size, hover-transform,
list-reset, marker-colour, alternate-selection, sticky, and visibility —
see optional/utilities.css’s header comment for the current active set);
most definitions in the file are staged (commented out) behind that same
curation call.
slashed.states — .sf-is-* markers. Exclusive prefix — utilities never use it (visibility helpers that don’t encode a runtime condition, like .sf-invisible/.sf-visible, live in slashed.utilities instead — see states.md § “Prefer native state”).
slashed.themes — token reassignments only. Lives in core/themes.css. Holds @media (prefers-color-scheme: dark) and the [data-theme="light|dark"] selectors that flip color-scheme and --sf-is-dark. Sits above slashed.{states, utilities, components} so theme overrides cannot be beaten by an equal-specificity component or utility rule. Consumers can extend this layer with forced-colors swaps, brand-palette scopes, or any other token-only reassignment (see optional/customize-example.css).
slashed.motion — animation tokens, keyframes, transition utilities. No component selectors. Gated behind @media (prefers-reduced-motion: no-preference). Transition tokens live in core/tokens.css — see motion.md. .sf-color-pulse animates --sf-color-primary-source-light lightness, demonstrating @property colour interpolation in oklch.
slashed.accessibility — :focus-visible, .sr-only, .skip-link, reduced-motion resets, plus the a11y patterns .sf-focus-parent and .sf-clickable-parent. High in the stack to override motion without relying solely on !important. Selective !important used only where override is a genuine accessibility barrier (focus ring, reduced motion, sr-only). .sr-only uses overflow: clip (modern consensus — avoids creating a new scroll container unlike the legacy overflow: hidden).
slashed.print — @media print only. Contains @page rule consuming --sf-print-* tokens. Authored colour is preserved by default; consumers opt into ink-on-paper via .print-no-color or force colour via .print-color-exact. !important is reserved for selectors whose semantics require defeating consumer CSS: the hide-list (nav, aside, button, input, select, textarea, dialog, [popover], .no-print), details > summary, and the two opt-in colour classes.
slashed.legacy — @supports not (…) fallbacks for the ~2022 baseline (first browsers with CSS cascade layers). Sits above slashed.print so fallbacks win, but below slashed.overrides so user CSS always wins.
slashed.overrides — ships empty. Consumer escape hatch.
Tokens
- Colors:
oklch()with relative color syntax for derived values;oklch(from …)for semantic alpha variants (ghost/subtle/muted) in core;color-mix(in oklab)for numeric tints/shades in the optional palette; the optional palette’s numeric alpha variants (-a5/-a10/-a30/-a50/-a80) are gated behind@supports (color: oklch(from red l c h)) @propertyregistration for 20 source colours (10-source-light+ 10-source-dark, 6 brand + 4 status) and the--sf-is-darkmode-flag integer — enables animation and typedinitialreset- Sizing:
clamp(min, preferred, max)— no bare viewport units in tokens - Aliases: semantic tokens always reference palette tokens via
var()— never literals - Component tokens: always
var(--sf-*)— never literals
Naming conventions
- Single dash vs double dash. Two parallel rules, applied consistently:
<role>-<variant>(single dash) — names a palette/shade variant; the name describes the colour itself (--sf-color-primary-source-light,--sf-color-primary-darker,--sf-color-success-strong,--sf-color-action-subtle). The token IS a colour value.<role>--<context>(double dash) — names an application slot; the name describes where/when the colour is applied (--sf-color-bg--hover,--sf-color-text--muted,--sf-color-link--hover,--sf-color-border--subtle,--sf-color-primary--hover,--sf-color-action--active). The token is consumed in a specific semantic context. Read--variantas “a flavour of the token to its left” and-variantas “a name-derived shade of the role to its left”. Brand hover/active slots (--sf-color-{primary,...}--hover,--hover/--active) live incore/tokens.css— they map interaction states to palette shades.
- Stability tiers. Token-file headers classify every token into one of
three SemVer tiers: PUBLIC (everyday knobs — brand/status sources,
resolved semantic tokens, scales, BEM consumer aliases), PUBLIC-ADVANCED
(same SemVer guarantee but niche/powerful — e.g. generative scale inputs and
the per-size typography sub-properties in
core/tokens.css), and INTERNAL (--sf-is-dark— implementation detail, may change without a major bump). The enumerated tier sets live inscripts/token-tiers.js; the full cross-reference is token-index.md. - Role: knob vs consumption. Orthogonal to tier (and carrying no SemVer
meaning), each token is a knob — an input you set (a literal primitive
value) — or a consumption value — a ready-to-use output you read,
derived from other tokens via
var(--sf-…). Role is computed deterministically from the declared value (roleOfinscripts/token-tiers.js) and surfaced in both index docs. - Semantic gap layer (base → semantic → component). Layout primitives
default directly to one of three semantic gap tokens —
--sf-gap(loose),--sf-content-gap(tight),--sf-gutter(wide) — which in turn read the--sf-space-*scale. Override the semantic token to move every primitive of that rhythm at once, or a per-primitive token (e.g.--sf-cluster-gap) for one element. The alias graph traverses at most 3 nodes (per-primitive → semantic → scale) with no duplicates/dangling/cycles. (--sf-section-pad→--sf-section-pad--mis a similar unsuffixed default.)
BEM consumer-API tokens
Many tokens — --sf-shadow-*, --sf-blur, --sf-gap, --sf-gradient-*,
--sf-scrollbar-*, --sf-optical-sizing — are not consumed by the framework
itself. They exist for your own BEM classes
(.card { box-shadow: var(--sf-shadow-m) }) and are exercised in
demo/index.html and validated by tests/.
Print class naming
Print helpers use the .print-* prefix: .print-only (show only on paper),
.no-print (hide on paper), .print-color-exact (force colour), and
.print-no-color (force ink-saving flatten). See the slashed.print layer.
Naming exceptions
A small number of classes are intentionally unprefixed (no sf- or sf-is-).
They are short, universally understood terms where a prefix would add noise
without reducing collision risk in practice.
| Class | Layer | Rationale |
|---|---|---|
.sr-only |
accessibility | Industry-standard screen-reader name |
.sr-only-focusable |
accessibility | Companion to .sr-only |
.skip-link |
accessibility | Common a11y pattern name |
.no-motion |
accessibility | Reads as a behaviour toggle |
.no-print |
Reads as a behaviour toggle | |
.print-only |
Reads as a behaviour toggle | |
.print-color-exact |
Self-documenting intent | |
.print-no-color |
Self-documenting intent |
Specificity
unlayered consumer CSS highest
slashed.overrides
slashed.legacy
slashed.print
slashed.accessibility
slashed.motion
slashed.themes
slashed.states
slashed.utilities
slashed.macros
slashed.layout
slashed.components
slashed.forms
slashed.base
slashed.reset
slashed.tokens lowest
Selectors stay low-specificity (single class, :root, element). Consumer overrides win without !important.
Extension points
| Mechanism | Example |
|---|---|
| Token override | :root { --sf-color-primary: #your-color } |
| Instance token | style="--sf-button-radius: var(--sf-radius-full)" |
| Layer override | @layer slashed.overrides { … } |
| Unlayered CSS | Author normally |
| Theme | [data-theme="dark"] { --sf-color-primary: … } |
Responsive design
- Fluid tokens —
--sf-space-*,--sf-text-*useclamp(). No@medianeeded. - Intrinsic primitives —
.sf-grid,.sf-sidebar,.sf-switcher,.sf-cluster,.sf-equal,.sf-content-gridcarry no@containerquery at all: they respond to their own width through intrinsic sizing (minmax(min(…),1fr), flex-basis,column-width), which is both container-relative and viewport-independent for free. Prefer this pattern. - Container-query primitives —
.sf-grid-cols-*,.sf-alternate,.sf-bentoneed a fixed column count at a threshold, which intrinsic sizing can’t express, so they use@container..sf-containerdeclares the named containersf-layout(see Container query contract below);.sf-alternatedeclares its ownsf-alternate. Their breakpoints are hard-coded in rem (root-relative, viewport-independent) — neverem, which inside@containerresolves against the container’s own fluid font-size and would re-couple the query to the viewport. - Breakpoints — a last resort. The framework ships no breakpoint tokens (custom properties can’t be used in
@media/@containerconditions); hard-code a rem value in your own query if you truly need one.
Container query contract
The framework declares two named containers:
| Selector | Container name | Type | Where consumed |
|---|---|---|---|
.sf-container |
sf-layout |
inline-size |
open — consumers can target with @container sf-layout (...) in their own CSS |
.sf-alternate |
sf-alternate |
inline-size |
framework-internal — the zigzag flip query is bound to this name so a nested .sf-alternate flips on its own width, not its outer container’s |
The .sf-bento and .sf-grid-cols-* (fixed-column and ratio) primitives use anonymous @container (...) queries on purpose. They are designed to react to whatever the nearest inline-size ancestor reports — typically .sf-container, but legitimately also a parent .sf-bento, .sf-alternate, or any user-declared container. This portability is the feature: drop .sf-grid-cols-3 inside any inline-size context and it adapts.
If precision is needed in a consumer layout — e.g. a .sf-grid-cols-3 that must respond to the outer .sf-container and ignore an intermediate container — the consumer wraps the grid in their own named container or uses @container sf-layout (...) directly in user CSS.
Bundle configuration
The optimal bundle (dist/slashed.optimal.css) — the recommended default —
includes core files plus classless form styling in order:
core/layers.css
core/tokens.css
core/tokens.layout.css
core/tokens.macros.css
core/reset.css
core/base.css
core/themes.css
core/layout.css
core/macros.css
core/states.css
core/motion.css
core/accessibility.css
core/print.css
optional/forms.css
Each bundle is emitted readable and minified with a source map
(*.css, *.min.css, *.min.css.map) via lightningcss; the minifier does
not down-level modern colour syntax (no targets), so light-dark() and
oklch(from …) survive. npm run build reports raw/gzip/brotli sizes.
Custom hand-built bundles must load core/layers.css first.
Four tiered bundles — each also emitted as a layer-flattened .flat variant,
so bundle.config.json lists eight outputs in total — are built by
scripts/bundle.js:
| Bundle | Contents |
|---|---|
slashed.optimal.css |
all core/ + forms |
slashed.full.css |
optimal + tokens.components + components (btn + card — the only two) + utilities (curated subset active, rest staged) |
optional/legacy.css is not bundled by default — add it explicitly when
you need back-compat shims. Because every rule sits in an @layer,
concatenation order within a bundle does not affect the cascade. The bundler
strips local @import statements (the explicit file list resolves them), so
the tokens.components import inside components.css is inlined by listing
the token file first. components.css and tokens.components.css ship
.sf-btn / .sf-card and their tokens — the only two components; utilities.css
ships a curated subset (heading, text-size, hover, list-reset, marker, selection,
and sticky helpers active, rest commented). Consumers can also build à la carte: raw
core/ plus hand-picked optional files. Each bundle also has a
layer-flattened .flat variant.
Performance
Plain CSS, no runtime. The notable costs:
transition: allwatches every animatable property and is a known performance footgun — it was removed from the public token API. Use scoped tokens (--sf-transition-colors/-transform/-opacity/-shadow); animatetransform/opacitywhere possible (compositor-only paths).oklch+ relative-colour tokens recompute when their inputs change. Animating a registered colour (e.g..sf-color-pulsemutating--sf-color-primary-source-light) re-derives everything downstream each frame — fine for a small accent, not for a token hundreds of elements read. A theme toggle re-resolves the graph once (negligible).@propertyregistration (30 colours + 5 state flags) is a one-time parse cost; immaterial.@layerhas no runtime cost — resolved at parse time. Low-specificity selectors keep style recalc fast.@containerestablishes a layout/style boundary (scoped invalidation, a win); avoid thousands of tiny containers.sf-layoutis declared once on.sf-container.- Bundle size — ship only what you use; serve compressed (the token-heavy
:rootgzips well). See bundle configuration. - Reduced motion —
core/accessibility.cssneutralises animation/transition underprefers-reduced-motion: reduce(and.no-motion) with property-level!important.
Known intentional tradeoffs
Deliberate behaviours, documented so they aren’t mistaken for bugs.
- Text-on-color uses a binary lightness threshold.
--sf-color-text--on-*usesign(var(--sf-contrast-threshold) - l)to pick black or white text (threshold defaults to 0.6). This is a binary decision: a colour at lightness 0.59 gets white text, 0.60 gets black. The framework’s default brand and status palettes are picked so every on-color foreground meets WCAG AA Normal (4.5:1) — verified bytests/tokens.spec.js. The trough at L 0.6 is inherent to any binary approach, so user-supplied colours that land in the L≈0.55..0.65 band may require an explicit override of their matching--sf-color-text--on-*token. A future upgrade path is CSScontrast-color()once it has broad support. - Smooth scroll is always enabled.
core/base.csssetsscroll-behavior: smoothonhtml. There is no focus-based override — keyboard users navigate with arrow keys and smooth scroll does not interfere. To suppress per-element, usescroll-behavior: autolocally. - The
basepalette ramp is V-shaped, not monotonic. Incore/tokens.css,base-*mixes--sf-color-textinto base for tints and base into text for shades, so it is surface-relative, not a perceptual lightness ramp.base-600can be lighter thanbase-400. This is intentional and differs from the conventional monotonic lightness ramp. - Palette token values are not part of the public API. The names
(
--sf-color-primary-source-light,--sf-color-action--hover, etc.) are stable and covered by SemVer. The computed colour values they resolve to may shift between minor releases as derivation formulas are refined. Pin your own overrides to the 6 source tokens if you need colour stability.
Deferred
@starting-style and CSS anchor positioning are only meaningful attached to
a specific element/component transition (dialog, popover, tooltip). SLASHED
ships only .sf-btn and .sf-card and stays BEM-first, so these belong to
consumer components rather than the framework and are out of scope here. The
component-free part — scroll-driven animation range tokens
(--sf-scroll-timeline-range-*) — already ships in core/tokens.css.