The DSSL docs
DSSL is deliberately small: three steps to a working stylesheet, and everything past step three is opt-in. The live boxes on this page are styled by exactly the rules shown next to them.
Quickstart: three steps
No bundler, no build step, no dependencies. The engine is a folder of plain JavaScript files, loaded by one script tag; your styles are plain .dssl text the browser fetches and applies itself.
<link rel="stylesheet/dssl" href="app.dssl">
<script type="module" src="dssl/boot.js"></script>
<!-- that is the whole install. works with any HTML,
React, Vue, Svelte, plain files: the engine
re-matches automatically when the DOM changes -->.go {
padding: 12 20
radius: md
background: color.accent // a built-in default token
color: white
font-weight: 700
transition: 150
hover { translate: 0 -2; shadow: md }
active { translate: 0 }
}<button class="go">Hello, DSSL</button>hover are states. You never have to touch the machinery below, but it's there when you want it.What you can safely ignore on day one: layers (your rules silently live in components), your own tokens (a useful default set is built in), behaviors, @signal, @container and motion. Each section below starts with the 20% you'll actually use.
Syntax
Braces, one declaration per line, no required semicolons. Newlines end declarations; ; is optional sugar for packing declarations. Comments are // line or block comments. Values span lines when parens are open or the line ends with a comma or colon.
.card {
background: color.card // no semicolon needed
padding: space.4; radius: md // optional semicolons
.title {
font: 16/1.4 color.ink // size / line-height / color
}
}
.one-liner { padding: 8; shadow: sm }
background: // values can continue
linear-gradient(135deg,
color.accent, color.accent/45)Tokens
Tokens are the variables. A sensible default set ships with the engine, color.ink, color.accent, space.1–.8, radius, shadow, font, so your first stylesheet needs zero setup. Define your own when the design outgrows the defaults; any bare identifier in a value resolves against them, and a misspelling is an error, not silence. A slash adds alpha via color-mix.
tokens {
color {
ink: #16161d
accent: oklch(62% 0.19 35)
}
radius { md: 14 }
shadow { md: 0 2 4 ink/10, 0 4 12 ink/8 }
}
.card {
background: color.card
color: color.ink/85 // alpha modifier
radius: md
shadow: md
}These dots resolve color.* tokens, flip the theme in the nav and watch them re-evaluate.
Built-ins: space.1–.8, radius.sm–full, shadow.sm–xl, font.sans/mono, and the color.* set. Override any of them from your own tokens block.
v0.7: numeric token keys. The built-in spacing scale stops at 8, but big pages need more room, so token keys can be numbers now: define tokens { space { 9: 96 } } and reference it as space.9 like any other token. Outside a tokens block a numeric property name is still an error, but the error now says exactly that instead of a shrug.
v0.4: tokens can be re-declared per element. Assign to a dotted token inside any rule and the custom property lands on that element; since custom properties inherit, every var() in the subtree re-resolves. This is how one card goes dangerous without a second stylesheet, and the re-declaration is still a validated token name, not a free-form string.
.danger {
--color.accent: color.err // re-declare accent for this subtree
--radius.md: 4 // sharper corners too, why not
}
// everything below re-resolves automatically:
.danger .badge {
color: color.accent-ink
background: color.accent // ← now the error red
}Inside this dashed box, --color.accent: color.ok, hover the dots and their hover accent comes out green, while the same dots anywhere else on this page hover oxblood. One declaration, whole subtree re-resolved.
Selectors & nesting
Every CSS selector works, elements, classes, ids, attributes, combinators, structural pseudo-classes. Nesting is native: a nested selector is a descendant; & substitutes the parent explicitly.
The structural set rides native matching, so it all just works: :nth-child(), :nth-of-type(), :not(), :is(), :where() and :has(). You do not need a class per list item, stop at the container and let the structure do the counting.
.gallery {
gap: space.4
.tile { shadow: sm } // .gallery .tile
&.featured { border-color: color.accent } // .gallery.featured
> .tile:nth-child(2n) { translate: 0 -2 }
> .tile:has(img) { shadow: lg } // relational: tiles that contain an image
hover { // states nest too (next section)
.tile { shadow: lg }
}
}States
An element's own pseudo-states are plain nested blocks, hover, focus, focus-visible, active, disabled, checked… The engine listens to the events and re-evaluates live, both entering and leaving a state. Rules inside a state block can target descendants (tooltips, dropdowns) or the element itself.
One state is not CSS at all: seen is engine-native, it fires once, the first time the element scrolls into the viewport. It is how you build reveal-on-scroll without a line of JavaScript (see Motion for the full choreography).
.doc-card {
transition: 200
hover {
translate: 0 -4
shadow: lg
}
focus-visible {
outline: 2 solid color.accent
outline-offset: 2
}
active { translate: 0 -1 }
}Pseudo-elements
Inline styles reach every element in the DOM, but a pseudo-element has no DOM handle, so ::before, ::after, ::selection and friends are the one thing DSSL compiles into a real stylesheet. Both spellings are the same rule: nested inside the block, or written as a full selector. Before v0.5 the full-selector form compiled ok: true and then matched nothing, a silent dead rule in a language whose whole pitch is loud errors. The trap is closed: the resolver splits any selector carrying :: and routes it down the same shadow-CSS pipeline as the nested form.
// all three spellings are the same rule now
.card::before { content: "" } // full selector
.card {
::before { content: "" } // nested
&::before { content: "" } // explicit &
}
// states, @media, @container and @signal compose as everywhere else
.card:hover::before { background: color.accent }
// vendor pseudo-elements pass through verbatim, trailing states included
input::-webkit-slider-thumb { background: color.accent }
// and the loud errors stayed loud:
// .card::befor { } → error: unknown pseudo-element "::befor"
// .card:seen::before { } → error: seen can't cross into shadow CSS
// .a, .b::before { } → error: mixed selector list, split itThe oxblood square is a .pe-tag::before rule written in the full-selector form in this page's stylesheet, the spelling that used to die silently. The dot runs animate: pe-pulse 1200 infinite alternate, v0.5's first loop. If both look right, the trap is staying closed.
@media · @container · @signal
The world around the element is one shape: CSS-shaped conditions, composed by nesting. v0.3 added one-word sugar for the conditions everyone writes, @media dark, @media 768 (this wide or wider), @media 480..960 (a range), while the full CSS form keeps working.
.panel {
padding: space.3
@media dark { // prefers-color-scheme, one word
background: color.ink
}
@media 768 { // viewport ≥ 768px
padding: space.6
}
@media 480..960 { // between 480 and 960
grid: 2
}
@media (width >= 768) { // full CSS shape still works
padding: space.6
}
@container (min-width: 480px) { // container queries: same shape
grid: 2
}
@signal theme == dark { // JS: DSSL.signal('theme', 'dark')
background: color.ink
}
@media <= 860 {
@signal nav == open { // composed: narrow AND menu open
display: block
}
}
}Also one word: @media light and @media reduced-motion.
Cascade & layers
The whole cascade in one sentence: later wins, layers trump order. No specificity arithmetic, no !important. Rules live in tokens < base < components < utilities < pin; yours default to components, most stylesheets never name a layer at all. The pin block is the single sanctioned escape hatch.
#hero .title { color: color.ink }
.legacy .panel .title { color: color.paper }
// ↑ WINS in DSSL (it is later), CSS would score the #id higher
@layer utilities {
.mt-0 { margin-top: 0 }
}
.wonky-badge {
pin: color color.accent-ink // beats everything DSSL emitted
}pin says so explicitly instead of leaking !important into every stylesheet.Semantic properties
All standard CSS properties pass through untouched. On top of them, DSSL speaks a small design vocabulary, properties that expand to the machinery they mean. Bare numbers are pixels; unitless where CSS wants them.
| Property | Example | Expands to |
|---|---|---|
size | size: 40 · size: 100% 40 | width + height (one or two values) |
row / column | row: gap 8 center wrap | flexbox + only the words you name (between, reverse, inline…) |
grid | grid: 3 · grid: auto-fill 220 | repeat(3, 1fr) · repeat(auto-fill, minmax(220px, 1fr)) |
stack | stack: center | overlay grid; children share cell 1/1 |
place / align | place: center | place-items/content · align-items: alignment implies a formatting context (see center) |
center | center: both · center: x · center: y | the centering primitive. The engine promotes any element to a grid/flex context first (inline → inline-grid, block → grid; an explicit display: still wins), so centering works on a span, a button, anything |
cover | cover: fixed | position + inset: 0 |
sticky | sticky: 0 | position: sticky; top: 0px |
font | font: 16/1.4 ink | font-size / line-height / color |
fluid | fluid: 16, 24 | a size that grows smoothly from 16px at a 360px window to 24px at 1200px (px, rem or em) |
shadow / radius | shadow: lg | token presets (sm/md/lg/xl) or raw values |
circle | circle: 36 | width + height + border-radius: 50% |
grow / shrink / basis | grow: 1 | flex-grow / flex-shrink / flex-basis |
truncate | truncate | ellipsis overflow (overflow hidden, nowrap) |
visually-hidden | visually-hidden | the a11y classic, invisible to eyes, announced by screen readers (clip pattern, one word instead of nine lines) |
z | z: 50 | z-index |
transition | transition: 150 snap · transition: 150 snap background-color, 300 transform | all 150ms, or per-property specs: duration · easing · property per comma part |
animate | animate: pop 400 snap | Web Animations API run of a motion block |
stagger | stagger: 60 · stagger: none | choreography, each element child's animation delay is offset by its DOM position × 60ms (works with seen; see Motion) |
Named easings: snap, smooth, bounce + the standard set.
Behaviors
A behavior is a named chunk of DSSL, declarations, states, even nested rules, spliced into any rule with use. Perfect for focus rings, chip styles, reset-ish bundles. No parameters in v0.3; compose by nesting behaviors.
behavior focus-ring {
focus-visible {
outline: 2 solid color.accent
outline-offset: 2
}
}
button { use focus-ring }
.card { use focus-ring }
// the dark palette on this very site is a behavior,
// reused by @media and @signal, define once, apply twiceOverlap
CSS will stack any two boxes on top of each other and say nothing, even when one of them sits at 45% opacity and the blend underneath turns to mud. DSSL refuses. The overlap property is a policy, not a paint instruction: after layout, the engine measures real boxes and, when a transparent element lands on something, it escapes along the shortest axis. The nudge travels through the separate translate channel, the one property transform never touches, so your transforms and animations never notice a fight, and a transition on translate makes the escape glide.
// one line, site-wide: nothing below 100% opacity may overlap anything
body { overlap: auto }
// or per element: this box always yields, opaque or not
.badge { overlap: deny }
// the deliberate exceptions, scrims, veils, translucent navs, opt out
.veil { overlap: allow }The policy inherits like font-family: set it once on body and every descendant carries it until something overrides. auto means yield only while your computed opacity is below 100%, an opaque element is never touched, and an element whose animation pulses opacity in and out becomes a candidate exactly while it is translucent. Bigger boxes anchor first, so decorations yield to content, and an element is never counted as an obstacle to its own subtree (that is containment, not collision). Unknown policies fail loud, like everything else: overlap: never → unknown overlap policy “never”.
The sibling feature: a decorative overlay parked on top of text also eats clicks and text selection, the pointer hits the veil, not the words underneath. pointer-events: none is the one-liner: the overlay keeps rendering, but the pointer falls through, so selection and buttons work again. Pair it with overlap: allow when the veil is intentional, the geometry stays honest while hit-testing gets out of the way.
.veil {
position: absolute
inset: 0
gradient: linear to bottom, color.ink/20 -> transparent
pointer-events: none // look, don't touch, selection passes through
overlap: allow // it is SUPPOSED to sit there
}This is the site's real policy working, not a picture: this page loads body with { overlap: auto }, the ghost is a genuine 45%-opacity element, and the shove you see is the engine's shortest-escape math running after layout. Flip the switch and the ghost gets its own overlap: allow, the same opt-out a scrim or a translucent nav uses, and glides right back on top.
Motion
Declare a motion with keyframe blocks (from, to, at 40%), the missing end interpolates from the element's natural state. Apply it with animate. The engine runs it through the Web Animations API, and every animation silently disables under reduced-motion unless the motion is essential: true. v0.5 adds infinite, loop forever, and alternate, which ping-pongs the direction instead of restarting.
motion pop {
from {
opacity: 0
translate: 0 16
scale: 0.9
}
}
.motion-box {
animate: pop 500 snap // name · duration · easing · fill
}
.pe-dot {
animate: pe-pulse 1200 infinite alternate // v0.5: loops, ping-pongs
}v0.4: choreography. Two additions turn motion into a reveal-on-scroll system, still with zero JavaScript. seen is an engine-native state that fires once when the element enters the viewport, content can never be trapped invisible, because with no IntersectionObserver everything counts as seen. And a parent with stagger: 60 offsets each element child's animation delay by its DOM position, so grids and lists cascade in instead of arriving as one wall.
motion rise-in {
from { opacity: 0; translate: 0 14 }
}
.pt-grid { stagger: 70 } // child i waits i × 70ms
.pt-card {
seen { // engine-native: fires once on scroll-in
animate: rise-in 420 snap
}
}These four cards were below your scroll position a moment ago, they cascaded in via seen + stagger: 70, no JS. Reload the page and scroll back here to watch it again.
Deform: bend, stretch, fold
The deform family gives a box give. bend bows the element in 3D, positive tips the top away from you, negative leans it forward, and bend-y bows it sideways. bend-up rocks it back on its bottom edge like a drawbridge, fold swings it up over its top edge like a page closing, and stretch is cartoon squash and stretch that keeps volume: stretched 1.2 wide, it honestly shrinks to 1/1.2 tall. Every member composes into the one transform channel, alongside anything you wrote there, so a single transition tweens them and states fire them for free.
.btn {
transition: 200 snap transform
hover { bend: 5 } // a gentle lean
active { bend: 16; stretch: 0.94 } // flex under the pointer
}
.flag { bend-y: -18 } // sideways bow
.card { hover { bend-up: 14 } } // drawbridge, bottom edge planted
.sheet { hover { fold: 14 } } // folds over its top edge
motion wobble {
from { bend: 0; stretch: 1 }
to { bend: 24; stretch: 1.12 } // deform works in keyframes too
}
.bouncy { animate: wobble 900 infinite alternate }And bend-around curves a container's children along an arc instead of deforming one element: positive degrees wrap the row around an invisible ball (a dome), negative opens it into a smile. The engine reads the container's real width, plants a pivot per child and rotates each one into place, hover states and bends on the children keep working, because their own transforms run inside the arc rotation. Unknown values fail loud like everywhere else: stretch: 0 → would flatten the element to nothing, bend-around: 200 → caps at ±170°.
All of it is live. The button flexes under :active because its cascade owns a transition on transform, the deform family lands in that same channel. The chip row is a real bend-around: -22 container; resize the page and the engine re-lays the arc from the measured width. The mini cards bend up and fold over on hover, bend-up plants the bottom edge, fold plants the top one, and both pivots are baked into the transform, so your own transform-origin is never touched.
Gradient tools
gradient replaces the function soup with a sentence: a kind, linear, radial or conic, an optional direction or shape, then color stops. Join stops with -> arrows, or just commas; both read the same. A stop can be a hex, a color name, a token, an alpha mix like color.ink/40, and may carry a trailing % position. And because a stop is a color by definition, one that is not fails loud at compile time instead of becoming a silently missing background.
.banner {
gradient: linear 135, color.accent -> color.ok
gradient-flow: 6s alternate // the gradient drifts, forever
}
.headline {
gradient-text: linear to right, color.accent, color.ink
}
.glow { gradient: radial circle at 30% 20%, color.card -> transparent }
.pie { gradient: conic from 45, color.accent, color.line, color.card, color.accent }gradient-text clips the same grammar into the glyphs (standard and -webkit plumbing included). gradient-flow loops background-position through the Web Animations API so the gradient drifts endlessly, duration, an optional easing, and alternate to ping-pong, and it holds still under reduced-motion, like every other motion here. If the element has no background to flow, the engine says so instead of animating nothing.
The banner is a real gradient-flow loop, the headline is gradient-text, and the three swatches are the kinds in one line each: linear with an angle, radial with a shape and position, conic with a start angle. The engine sized the banner's background to 200% automatically, a gradient at 100% has nowhere to slide.
More forms
The sentence grew up in 0.12.0. Any kind can be repeating, the blend can happen in another colour space, a lone position between two stops moves the midpoint, two positions make a hard band, mesh lays soft blobs of colour over a base, and layers are joined with +, first on top.
.stripes { gradient: repeating linear 45, #fff 0 10, color.ink 10 20 } // bare numbers are px (deg on conic)
.blend { gradient: linear 90 in oklch, #ff0055 -> #00d4ff } // no muddy middle
.mid { gradient: linear 90, #f00 -> 30% -> #00f } // the midpoint sits at 30%
.band { gradient: linear 90, #f00 0 30%, #00f 30% 100% } // a hard edge
.aurora { gradient: mesh #14102a, #ff7a1a at 15% 20%, #c2185b at 80% 30% size 45%, #3949ab at 50% 95% }
.sheen { gradient: radial at top left, #fff/40 -> transparent + linear 135, #123 -> #456 }Left to right: repeating stripes, the same red to cyan blended in sRGB and in oklch, a hard band, and a mesh.
Texture
texture: lays a generated image on top of whatever background an element already has: a plain colour, a gradient, or several layers. The author's background is never replaced, and removing the texture puts it back exactly as it was. There are 40 presets. They are not magic: each is a stored combination of a few generators and settings, so anything a preset does you can write yourself.
.card { background: #c9a66b; texture: wood }
.sheet { background: linear-gradient(135deg, #ff7a00, #7a1fff); texture: grain intensity 0.6 }
.poster { background: #e9e2d0; texture: paper, scanlines intensity 0.1 } // a comma stacks layers
// your own: start from a generator, name it with a behavior
behavior old-leather {
texture: bump scale 6 depth 4 color #1b1008 color2 #6b4a2b blend overlay
}
.sofa { background: #5a3a22; use old-leather }Every swatch above is a plain box with one rule, like .tx-marble { texture: marble }, over the same tan colour. Hover a swatch for what it is meant to look like. Add symmetrical to any image texture and every other tile is mirrored, so the edges always meet and the repeat is twice as far apart. The last ten are wood: drawn as growth rings, not stretched noise, so they take figure, warp, pores, knots and planks. See a whole page built from them, with the textures baked into real CSS by dssl build.
Settings
Settings go after the name, in any order. Only write the ones you want to change.
| setting | what it does |
|---|---|
scale | feature size in px: how big the grains, cells or lines are |
intensity | how strong the layer is, 0 to 1 (more than 1 over-drives it) |
contrast | noise contrast: 1 is as generated, higher is harsher |
threshold | noise cut-off: only values above it show (glitter uses a high one) |
contour | draw thin lines where the noise crosses the threshold, instead of a plain cut: veins, rings, contours |
octaves | noise detail layers, 1 is smooth, 5 is rough |
seed | a different random pattern with the same look |
stretch | stretch noise features along x and y, e.g. stretch 40 1 for wood grain |
depth | bump height: how deep the relief looks |
light | bump light direction in degrees |
angle | direction of lines and patterns, in degrees |
thickness | line or grid-line width in px |
radius | dot size as a fraction of the cell |
softness | how soft the edges of dots and lines are |
animate | milliseconds per shimmer step (film-grain flicker); 0 is still |
type | noise flavour: fractal is clouds, turbulence is veins |
blend | how the layer mixes with what is under it |
color | the main colour (dark end of noise, ink of lines and dots) |
color2 | the second colour (light end of noise, background cell of patterns) |
figure | rings generator: how strongly the growth rings arch into a cathedral figure, 0 is straight grain |
warp | rings generator: how much each ring wobbles along its length |
pores | rings generator: fine fibre and pore streaks between the rings |
knots | rings generator: how many knots in one tile (the pattern repeats, so keep it small) |
planks | rings generator: lay it out as planks this many px tall, with a seam and a slightly different board each |
symmetrical | mirror every other tile, so each edge meets its own reflection: no seam, and the repeat is twice as far apart. x mirrors left to right, y top to bottom, both (the default) does both. Works on the image-based textures (noise, bump, rings, waves, hex). |
invert | swap the two colours |
Generators: noise bump lines dots grid checker weave carbon waves hex rings Colours can be tokens and follow them, in dark mode too. animate makes film grain shimmer, and stands still for visitors who ask for reduced motion. Textures are SVG filters and gradients, drawn once and cached. Inside an aura, texture(grain) makes the light and the particles grainy.
dssl build leaves it in the runtime and says so in its report.Aura: light and particles
An aura is a glow and particles around an element. There are no presets. Fire, an energy aura, snow and sparks are all parameters, and you name your own with a behavior. That is the point: you get the knobs, not someone else's idea of fire. Each kind of layer lives in its own slot, so rules stack through the cascade like anything else.
| property | what it sets |
|---|---|
aura-glow | the glow slot, a soft light around the element |
aura-particle | the particles slot (also spelled aura-particles) |
glow-particle | the particles slot, every particle glowing (also glow-particles) |
aura-glow-particle | both slots at once, settings shared |
aura | both slots, written as glow ..., particles ...; replaces what was there |
aura-glow: none clears only the glow. aura: none clears both.
.orb { aura-glow: color.accent 28 pulse 1600 } // colour, then radius
.hot { aura-particle: #ffd24a -> #ff6a00 -> rgba(255,40,0,0) 60 up } // colour stops, count, direction
.big { aura: glow #ff7a00 30 flicker 0.3, particles #fd0 count 60 shape flame glowing behind }The first colour and number are positional (a glow's colour and radius, a particle's colours and count); everything else is a named setting. Colour stops are joined with -> and blend along a particle's life. These are the five recipes from the guide, running live. Copy one, change it, rename it.
behavior fire {
aura-glow: #ff7a00 36 pulse 900 flicker 0.35
aura-particle: #ffe27a -> #ff8a1f -> rgba(255,40,0,0) count 70 size 8 18 life 650 1100
speed 40 120 dir up spread 40 from top gravity -40 turbulence 70
shape flame glowing behind
}
.torch { use fire }
.blue { use fire; aura-glow: #3a7bff 36 pulse 900 flicker 0.35 } // change one thing at the use siteGlow settings
| setting | what it does |
|---|---|
color | the light colour (default: the element's text colour) |
radius | how far the glow reaches, in px |
intensity | brightness, 1 is normal |
pulse | milliseconds for one breath in and out; 0 is steady |
depth | how deep the pulse goes, 0 to 1 |
flicker | random flicker, like a flame: 0 is steady, 1 is wild |
seed | a different random flicker with the same look |
texture | texture(grain scale 2): break the light up with a texture |
essential | keep animating even when the visitor prefers reduced motion |
unclipped | let the light spill out of a parent that clips (overflow: hidden, auto, scroll). Without it the aura is cut at that edge, like the element itself |
always | keep emitting even when the element cannot be seen (opacity 0, display: none, visibility: hidden, on it or an ancestor): for an invisible anchor that smoke or sparks come out of |
z | the z-index of the layer this light is drawn on: `z 5` puts it under a hill with z-index 10. It meets elements in the page's root stacking context |
Particle settings
| setting | what it does |
|---|---|
color | colours over a particle's life: color #ff0 -> #f60 -> transparent |
count | how many particles are alive at once |
size | diameter in px: size 4, or size 10 4 (start and end of a random range) |
life | how long one lives, in ms: life 900, or life 600 1200 |
speed | px per second: speed 60, or speed 30 90 |
dir | where they head: up, down, left, right, out, in, or degrees (-90 is up) |
spread | how wide the fan of directions is, in degrees |
gravity | px/s squared; negative pulls up (flames), positive down (snow) |
drag | air resistance: how fast particles slow down |
turbulence | random sideways wobble, px/s |
from | where they are born: edge, bottom, top, left, right, center, corners, thumb (range sliders) |
shape | circle, spark, square, ring, star, flame, smoke or line |
grow | size at the end of life as a multiple of the start: 0.2 shrinks, 3 swells |
blend | add makes overlapping particles brighter, normal just stacks them |
glowing | soft glowing particles that add light |
behind | draw only outside the element's box, as if the element hid them |
seed | a different random pattern with the same look |
texture | texture(grain): grainy particles |
essential | keep animating even when the visitor prefers reduced motion |
unclipped | let the particles leave a parent that clips (overflow: hidden, auto, scroll). Without it they are cut at that edge |
always | keep emitting even when the element cannot be seen (opacity 0, display: none, visibility: hidden, on it or an ancestor): for an invisible anchor that smoke or sparks come out of |
z | the z-index of the layer these particles are drawn on: `z 5` puts them under a hill with z-index 10. It meets elements in the page's root stacking context |
How it behaves
- Where it draws. One transparent canvas covers the page and never catches the pointer. The glow is drawn only outside the element;
behinddoes the same for particles, so flames lick out from behind a box instead of crossing its face. - Only what is seen. Hidden, off-screen or removed elements emit nothing, and when the last aura goes the loop stops.
- Budget. At most 1800 particles are alive across the page, shared fairly. Glows are painted once and re-lit each frame: 40 pulsing glows cost under a millisecond.
- Reduced motion. A still glow and no particles. Add
essentialto a layer that must keep moving. - States.
hover { aura-particle: ... }works; moving away lets what is alive finish. - Limits. Runtime only (
dssl buildreports it), and a light glow on a light page looks like paint, because it adds light.
Range sliders
A range input is styled through vendor pseudo-elements, and every engine has its own: ::-webkit-slider-runnable-track and ::-webkit-slider-thumb for Chrome, Edge, Safari and Opera, ::-moz-range-track, ::-moz-range-thumb and ::-moz-range-progress for Firefox. slider: writes both sets from one description.
input.volume {
slider: track 6 #445566 radius full, fill #ff7a00,
thumb 22 #fff, ring 3 #ff7a00, glow #ff7a00 14, ticks 10 #8890a0
}
input.price { // a histogram: the bars colour themselves as the value passes them
slider: graph 20 45 70 90 60 35 50 80 95 40 60 75, graph-height 52, graph-gap 3,
fill #77ccff, track 3 #34445a, thumb 14 #fff, shape bar
}
input.mood { // particles leave the thumb, wherever the value is
slider: track 10 #2b3040 radius 3, fill #5be2a0, thumb 24 #5be2a0, shape diamond
aura-particle: #bfffe0 -> rgba(91,226,160,0) count 40 life 700 speed 30 70 dir up from thumb glowing
}ring, glow and ticks
a histogram slider
a diamond thumb that sheds particles
| setting | form |
|---|---|
track | track [height] [color] [radius <n>|full] |
fill | fill <color> | fill none |
thumb | thumb <size> [color] |
shape | shape circle | square | diamond | pill | bar |
ring | ring <width> <color> |
glow | glow <color> <radius> |
ticks | ticks <count> [color] [length] |
graph | graph <v1> <v2> ... (each 0 to 100) |
graph-height | graph-height <px> |
graph-gap | graph-gap <px> |
Settings are separated by commas, and a leftover word is an error, not noise. The fill and the bars follow the value through --dssl-p, which the engine keeps current (set input.value from JavaScript, then call DSSL.refresh()). The WebKit and Blink rules are tested in Chrome. The Firefox rules are generated but have not been run in Firefox yet. One gotcha: the slider parts are real CSS pseudo-elements, so they follow normal specificity. To give every slider a default that your own classes can replace, write it as :where(input[type=range]).
Glass and tilt
glass frosts whatever is behind an element. tilt turns an element toward the pointer in 3D. They were added to build this site, and they work on any element.
.pane {
glass: 18 saturate 1.6 // blur what is behind it
tilt: 10 lift 1.04 glare // follow the pointer
border: 1 solid white/35
background-image: radial-gradient(circle at var(--dssl-gx, 50%) var(--dssl-gy, 50%), white/28, transparent 55%)
}glass
It writes a backdrop-filter and a faint wash of the text colour, so it works on light and dark pages. It is plain CSS, so dssl build keeps it. Write your own background after it to replace the wash, or say tint none. glass: none turns it off.
| setting | what it does |
|---|---|
blur | a number: how many px to blur what is behind the element (default 16) |
saturate | saturate <n>: colour boost for what shows through, 1 is unchanged (default 1.5) |
bright | bright <n>: brightness of what shows through, 1 is unchanged |
tint | tint <color> | tint none: the milky layer on top (default: a faint wash of the text colour) |
tilt
The engine adds a perspective rotation after whatever transform you wrote, and only changes custom properties while the pointer moves, so nothing restyles. It ignores touch, stays flat under reduced motion, and stops while the page is frozen. It combines with translate, scale and rotate. Do not combine it with bend or fold, which also write the transform.
| setting | what it does |
|---|---|
max | a number: the largest tilt in degrees (default 8) |
lift | lift <n>: scale while the pointer is over it, e.g. 1.04 |
perspective | perspective <px>: how strong the 3D depth looks (default 900) |
speed | speed <0.05 to 1>: how quickly it follows (default 0.18) |
invert | invert: tilt away from the pointer instead of toward it |
glare | glare: also keep --dssl-gx and --dssl-gy (0% to 100%) pointing at the pointer, for your own highlight |
glare is the trick for a shine: the engine keeps --dssl-gx and --dssl-gy pointing at the pointer, and you use them in any gradient you like.
Fonts
Loading a font used to mean a raw { } block of plain CSS. Now it is its own block, one line per family.
fonts {
Fraunces: 500 700 800 // from Google Fonts, these weights
"Hanken Grotesk": 400 700 italic // and the italics
Inter: 100..900 display optional // a variable range, and how it swaps in
Brand: from "/fonts/brand.woff2" 400 // your own file: becomes @font-face
Brand: from "/fonts/brand-bold.woff2" 700
}
tokens { font { display: "Fraunces", Georgia, serif } }At run time the engine adds the <link> to the page head (with preconnects), and removes it again if no stylesheet asks for it any more. dssl build writes an @import as the first line of the CSS. Your own files become @font-face with the format guessed from the extension. One file holds one weight, or one variable range, so use a line per file.
Google's servers see everyone who loads one of their fonts, which some privacy laws treat as sharing a visitor's address. If that matters to you, download the files and use from.
Inspector and hot reload
Two tools for the person writing the styles. Neither is loaded for visitors: they are imported the first time you ask.
DSSL.inspect() // hover any element: which rule won each property, and in which file and line
DSSL.watch() // edit a .dssl file and the open page restyles, with no reload
// or switch them on from the page, for a dev build only
<script type="module" src="/dssl/boot.js" data-watch data-inspect></script>
// or from the address bar: ?dssl-watch ?dssl-inspectThe inspector outlines the element under the pointer and lists what applied (each declaration with its file, line and selector), then every matching rule in cascade order, with its layer and any state. Click to pin an element so you can read the panel; Esc releases it, and a second Esc closes the panel. It is the same answer as DSSL.explain(), without opening the console.
Hot reload polls the .dssl files you loaded (and any built .css the page links) and applies a file again the moment its text changes. The page does not reload, so scroll position, open menus and form input survive. A mistake shows as a toast with the line and message, and fixing it recovers. DSSL.watch(false) stops it.
node cli/dssl.mjs dev . # serve this folder, never cached, with hot reload added to every page
node cli/dssl.mjs dev site --port 4000 --inspectHot reload needs a server that lets the browser re-fetch files. dssl dev is one; most dev servers are too. It does not follow inline <style type="text/dssl"> blocks, because there is no file to watch.
Shipping without the engine
dssl build turns the static part of your styles into plain CSS. A page that links it is styled on first paint, with JavaScript off, in a crawler, and in server-rendered HTML.
node cli/dssl.mjs build tokens.dssl site.dssl -o site.css --reset --sourcemap # several files, load order
node cli/dssl.mjs build tokens.dssl site.dssl -o site.css --inline-tokens # the fastest CSS| promise | how it is checked |
|---|---|
| JavaScript off, first paint is right | every page of this site, built CSS with scripts off against the engine, about 100 properties on each of 5,000+ elements: identical |
| Startup and churn close to plain CSS | a 2,000-card page against hand-written CSS: startup +0%, churn +7% (0% with --inline-tokens), restyle +20% (0% inlined) |
| The engine itself | not at parity: load event 17x, churn 4x. A built page does not pay that |
| Strict CSP | the engine uses the CSSOM and constructed stylesheets, never a script-made style element: zero violations under style-src 'self'. Textures need img-src data: |
| Server rendering | public/dssl/server.js compiles with no DOM. This site links its compiled CSS in the HTML it sends |
| Devtools show .dssl lines | --sourcemap: Chrome's DevTools shows the file and line next to each rule. For a runtime page use DSSL.inspect() |
A build cannot keep what needs the engine: @signal, animate, aura, tilt, bend, stretch, overlap and the slider fill. Selectors are written with :where() so the later rule wins exactly as it does in the engine. The guide has the numbers, the cases where a build differs on purpose, and how to reproduce all of it.
Designer, import and lint
The interactive tools live in the editor, not on this site. The VS Code extension has a designer (command palette: DSSL: Open the designer): a slider or picker for every aura, texture and slider setting, live previews drawn by the same renderer the engine uses, graphs, and the DSSL it writes, checked by the real compiler as you edit. Insert at cursor puts it in your file and Load from selection reads an existing declaration back into the controls.
Install: in VSCodium, search DSSL in the Extensions tab, or open it on Open VSX. In VS Code, install the .vsix from the repo (Extensions, Install from VSIX).
node cli/dssl.mjs import styles.css -o styles.dssl # CSS to DSSL, and a report of what could not carry overThe importer drops semicolons, turns 16px into 16, rewrites transition and font:, turns @keyframes into a motion, moves !important into pin { }, and keeps @font-face, @supports and @import in a raw { } block. Anything DSSL rejects is commented out and listed, so the file you get always compiles.
dssl check also warns about text with too little contrast, a focus outline removed with nothing to replace it, and tap targets under 24px. They never fail a build unless you pass --strict. The linter found four real contrast failures in this site's own stylesheets the day it was written.
Errors that talk back
CSS fails silently; DSSL refuses to compile and tells you where and what it expected, with a did-you-mean built from edit distance over every known property, token and motion. The playground shows this live.
.badge {
colour: red // ✗ unknown property "colour"
// → did you mean "color"?
background: color.acnt // ✗ unknown value "color.acnt"
// → did you mean "color.accent"?
flex-direction: banana // ✗ unknown value "banana"
}--*) pass through unvalidated, and unknown identifiers inside calc() and friends are your business, the loud checks protect the design vocabulary, not the pressure valve.v0.7: compiler warnings. Some things compile, run, and are still probably not what you meant. Those are warnings: ok stays true, but the console says it out loud. The first one: inside a single rule, a layout shorthand that expands to display (column:, row:, grid:, stack:) silently overriding an explicit display: written earlier in the same rule, later wins, so your display: none is dead and the menu stays open. The warning names both lines and tells you which order wins.
.nav-links {
display: none // ✗ warning: "column:" expands to display: flex,
column: gap 2 // overrides the earlier "display: none" (line 2)
// , the menu stays open
}
// fixed: shorthand first, or the display you mean to win last
.nav-links {
column: gap 2
display: none // intentional, and now warning-free
}JS API
The engine exposes a small, synchronous surface. Signals are the bridge: they re-evaluate every @signal rule and mirror onto data-dssl-* attributes for DevTools visibility.
| Call | What it does |
|---|---|
DSSL.load(urls, opts?) | fetch + apply .dssl files in order (shared token registry) |
DSSL.apply(src, opts?) | apply a source string; { scope, name, replace } for scoped stylesheets |
DSSL.compile(src) | parse + resolve without touching the DOM, powers the playground |
DSSL.signal(name, value) | set/clear a signal; @signal rules re-evaluate instantly |
DSSL.replay(sel?) | re-run animations on matching elements |
DSSL.refresh() | full re-compute (rarely needed, the engine watches the DOM) |
DSSL.errors() | structured errors from the last apply |
DSSL.freeze() / unfreeze() | stop (or resume) all ambient restyling, see Performance |
DSSL.explain(el, opts?) | matched rules, file/line, winning declarations for one element, see Performance |
Auto-boot picks up link[rel="stylesheet/dssl"] and style[type="text/dssl"], then fires dssl-ready on window. <html data-dssl-reset> enables the small built-in reset.
Performance, freeze, explain & the cost of live
DSSL is a runtime: it walks the DOM, matches rules, and writes inline styles. That buys you live tokens, live signals and live layout, and it costs more than a static stylesheet, by design. Here is the honest ledger and the two tools that tip it back.
Wait for dssl-ready, not DOMContentLoaded
The engine styles the DOM after the parser resolves, which lands after DOMContentLoaded. Code that measures layout too early will read a half-styled world, padding: 0, border-radius: 0, a default border, and then watch it resolve a moment later. If your script measures, animates, or mounts on top of styled geometry, wait for the engine:
// wrong: reads geometry before the engine's first pass
window.addEventListener('DOMContentLoaded', measure)
// right: the engine announces readiness (with its version)
window.addEventListener('dssl-ready', (e) => {
measure() // styles are fully applied, measure whatever you like
})DSSL.freeze(), keep the look, drop the cost
A page that does not need live restyling should not keep paying for it. freeze() detaches every observer and listener, the MutationObserver, ResizeObserver, IntersectionObserver, media listeners, state events, the overlap heartbeat, and leaves the resolved inline styles exactly where they are. The page keeps its look at zero engine cost, which is what makes ten-thousand-node pages viable. Explicit calls still work: apply(), signal(), refresh() and replay() each drain synchronously, and unfreeze() re-arms everything and recomputes once.
// render the whole product grid, then stop reacting
DSSL.load(['/styles/app.dssl']).then(() => {
const stats = DSSL.freeze()
// → { frozen: true, engine: 'global', elements: 10432, rules: 311, shadowRules: 4 }
})
// months later, a modal needs styling:
DSSL.apply(modalStyles) // explicit calls still drain
DSSL.unfreeze() // …or go fully live againseen reveals, media flips and overlap nudges all sleep. If your page lives on interaction states, freeze is not for it, use it on the rendered-out pages that are just sitting there being read.DSSL.explain(el), the DevTools panel
Inline styles trade the cascade for a style attribute, and "which rule wrote this one declaration?" is the honest cost. explain() hands the cascade back as data: every rule that matched, with its source file and line, every state/media/signal condition attached, the winning declaration per property, and the rule that beat the others for it.
const rep = DSSL.explain(document.querySelector('.doc-card'))
// rep.matched, [{ id, sel, layer, file, line, states, media, signals, decls }]
// rep.wins , [{ prop, value, by: { sel, file, line } }] the final cascade
// rep.pseudo , ::before / ::after shadow rules touching this element
// + a readable group printed to the console (pass { quiet: true } for data only)The honest ledger
For creative sites, prototypes, docs and design-system playgrounds, the live model is the point, iteration speed beats parse speed. For a ten-thousand-node application page, combine the tools: keep stylesheets small, prefer structural selectors over class spam, freeze after the first paint when the page goes static, and use explain() instead of console archaeology when something looks off. The engine also pre-filters every rule through a cheap class/tag/id gate before native matching, so a full recompute skips the obvious non-matches instead of asking the browser rule by rule.
Interop: React, hydration & friends
DSSL is deliberately framework-agnostic: it styles whatever DOM exists, so it works under React, Vue, Svelte or plain HTML. But if you render with React there is one known, harmless notice you should understand, and this very site is the living example.
style="--dssl-*…" attributes it did not put there, and logs one hydration diff to the console. React explicitly does not patch them away, it leaves the engine's styles alone. Nothing is wrong: the styles are working exactly as intended.In dev builds that single notice can surface as a "1 Issue" overlay badge, because Next's dev tools wrap console.error and count everything they see. This site installs an assignment trap: the moment Next assigns its own collector, the shim captures it and re-publishes the filter as the outermost layer, so DSSL's own diff is swallowed and re-logged as an explained info line before Next can count it. Any genuine hydration mismatch still flows through to the collector at full volume. In production React does not diff attributes at all, so the notice disappears entirely; the shim is dev-only.
// assignment trap: become the OUTERMOST console.error
// the moment Next assigns its own collector
let inner = console.error, trapped = false;
Object.defineProperty(console, 'error', {
configurable: true,
get: () => (trapped ? outer : inner),
set: (fn) => { inner = fn; trapped = true; }
});
function outer(...args) {
const s = args.map(String).join(' ');
// swallow ONLY the DSSL-attribute hydration diff
if (s.includes('A tree hydrated but some attributes')
&& s.includes('--dssl')) {
console.info('DSSL styled this page before React hydrated. Expected interop.');
return;
}
return inner.apply(console, args); // genuine issues flow through
}Two more interop notes worth knowing. First, if a framework re-renders a subtree and removes elements the engine had styled, the touched registry notices the removal via MutationObserver and cleans up, no leaked inline styles. Second, if your framework toggles attributes the engine matches on (like data-dssl-* signal mirrors), change them through DSSL.signal() rather than DOM writes, so rules re-evaluate on the same tick instead of waiting for the observer.