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.

step 1: index.html
<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 -->
step 2: app.dssl
.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 }
}
step 3: use it
<button class="go">Hello, DSSL</button>
That's the entire mental model. Bare numbers are pixels, known token names just resolve, and nested blocks like 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.

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

scoped-tokens.dssl
.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.

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

states.dssl
.doc-card {
  transition: 200

  hover {
    translate: 0 -4
    shadow: lg
  }

  focus-visible {
    outline: 2 solid color.accent
    outline-offset: 2
  }

  active { translate: 0 -1 }
}
Hover meThis card lifts on hover, and settles back down when you leave.

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.

pseudo.dssl
// 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 it
styled by .pe-tag::before, the full-selector formpe-pulse · 1200ms · infinite alternate

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

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

@signal docdemo == hot: cold

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.

cascade.dssl
#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
}
Why no specificity? Because it never survives contact with a real codebase. Source order is readable, predictable, and refactorable, and when you truly need to win, 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.

PropertyExampleExpands to
sizesize: 40 · size: 100% 40width + height (one or two values)
row / columnrow: gap 8 center wrapflexbox + only the words you name (between, reverse, inline…)
gridgrid: 3 · grid: auto-fill 220repeat(3, 1fr) · repeat(auto-fill, minmax(220px, 1fr))
stackstack: centeroverlay grid; children share cell 1/1
place / alignplace: centerplace-items/content · align-items: alignment implies a formatting context (see center)
centercenter: both · center: x · center: ythe 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
covercover: fixedposition + inset: 0
stickysticky: 0position: sticky; top: 0px
fontfont: 16/1.4 inkfont-size / line-height / color
fluidfluid: 16, 24a size that grows smoothly from 16px at a 360px window to 24px at 1200px (px, rem or em)
shadow / radiusshadow: lgtoken presets (sm/md/lg/xl) or raw values
circlecircle: 36width + height + border-radius: 50%
grow / shrink / basisgrow: 1flex-grow / flex-shrink / flex-basis
truncatetruncateellipsis overflow (overflow hidden, nowrap)
visually-hiddenvisually-hiddenthe a11y classic, invisible to eyes, announced by screen readers (clip pattern, one word instead of nine lines)
zz: 50z-index
transitiontransition: 150 snap · transition: 150 snap background-color, 300 transformall 150ms, or per-property specs: duration · easing · property per comma part
animateanimate: pop 400 snapWeb Animations API run of a motion block
staggerstagger: 60 · stagger: nonechoreography, 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.

behaviors.dssl
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 twice

Overlap

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.

overlap.dssl
// 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.dssl
.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
}
opaque card, it never movesthe ghost badge is opacity: 0.45, parked on top of this text. Under the site-wide policy it is not allowed to stay there.
ghost · opacity: 0.45

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.dssl
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
}
motion pop: replay me

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.

reveal.dssl
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
  }
}
1parse
2resolve
3match
4style

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.

deform.dssl
.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°.

hover · bend-up
hover · fold
alphabetagammadeltaepsilon

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.

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

this banner flows: gradient-flow: 7s alternate
gradient text, one line of DSSL

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.

gradient-forms.dssl
.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.

texture.dssl
.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 }
grain
noise
rough
sand
paper
cloud
concrete
stone
crumpled
glitter
frost
marble
wood
oak
walnut
cherry
maple
pine
mahogany
ebony
birch
oak-planks
walnut-planks
bark
leather
brushed
canvas
linen
denim
carbon
scanlines
halftone
dots
lines
grid
checker
crosshatch
stripes
waves
hex

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.

settingwhat it does
scalefeature size in px: how big the grains, cells or lines are
intensityhow strong the layer is, 0 to 1 (more than 1 over-drives it)
contrastnoise contrast: 1 is as generated, higher is harsher
thresholdnoise cut-off: only values above it show (glitter uses a high one)
contourdraw thin lines where the noise crosses the threshold, instead of a plain cut: veins, rings, contours
octavesnoise detail layers, 1 is smooth, 5 is rough
seeda different random pattern with the same look
stretchstretch noise features along x and y, e.g. stretch 40 1 for wood grain
depthbump height: how deep the relief looks
lightbump light direction in degrees
angledirection of lines and patterns, in degrees
thicknessline or grid-line width in px
radiusdot size as a fraction of the cell
softnesshow soft the edges of dots and lines are
animatemilliseconds per shimmer step (film-grain flicker); 0 is still
typenoise flavour: fractal is clouds, turbulence is veins
blendhow the layer mixes with what is under it
colorthe main colour (dark end of noise, ink of lines and dots)
color2the second colour (light end of noise, background cell of patterns)
figurerings generator: how strongly the growth rings arch into a cathedral figure, 0 is straight grain
warprings generator: how much each ring wobbles along its length
poresrings generator: fine fibre and pore streaks between the rings
knotsrings generator: how many knots in one tile (the pattern repeats, so keep it small)
planksrings generator: lay it out as planks this many px tall, with a seam and a slightly different board each
symmetricalmirror 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).
invertswap 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.

Runtime only. Texture is an engine feature, so 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.

propertywhat it sets
aura-glowthe glow slot, a soft light around the element
aura-particlethe particles slot (also spelled aura-particles)
glow-particlethe particles slot, every particle glowing (also glow-particles)
aura-glow-particleboth slots at once, settings shared
auraboth slots, written as glow ..., particles ...; replaces what was there

aura-glow: none clears only the glow. aura: none clears both.

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

fire
ki
embers
electric
snow
fire.dssl
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 site

Glow settings

settingwhat it does
colorthe light colour (default: the element's text colour)
radiushow far the glow reaches, in px
intensitybrightness, 1 is normal
pulsemilliseconds for one breath in and out; 0 is steady
depthhow deep the pulse goes, 0 to 1
flickerrandom flicker, like a flame: 0 is steady, 1 is wild
seeda different random flicker with the same look
texturetexture(grain scale 2): break the light up with a texture
essentialkeep animating even when the visitor prefers reduced motion
unclippedlet 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
alwayskeep 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
zthe 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

settingwhat it does
colorcolours over a particle's life: color #ff0 -> #f60 -> transparent
counthow many particles are alive at once
sizediameter in px: size 4, or size 10 4 (start and end of a random range)
lifehow long one lives, in ms: life 900, or life 600 1200
speedpx per second: speed 60, or speed 30 90
dirwhere they head: up, down, left, right, out, in, or degrees (-90 is up)
spreadhow wide the fan of directions is, in degrees
gravitypx/s squared; negative pulls up (flames), positive down (snow)
dragair resistance: how fast particles slow down
turbulencerandom sideways wobble, px/s
fromwhere they are born: edge, bottom, top, left, right, center, corners, thumb (range sliders)
shapecircle, spark, square, ring, star, flame, smoke or line
growsize at the end of life as a multiple of the start: 0.2 shrinks, 3 swells
blendadd makes overlapping particles brighter, normal just stacks them
glowingsoft glowing particles that add light
behinddraw only outside the element's box, as if the element hid them
seeda different random pattern with the same look
texturetexture(grain): grainy particles
essentialkeep animating even when the visitor prefers reduced motion
unclippedlet the particles leave a parent that clips (overflow: hidden, auto, scroll). Without it they are cut at that edge
alwayskeep 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
zthe 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; behind does 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 essential to a layer that must keep moving.
  • States. hover { aura-particle: ... } works; moving away lets what is alive finish.
  • Limits. Runtime only (dssl build reports 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.

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

settingform
tracktrack [height] [color] [radius <n>|full]
fillfill <color> | fill none
thumbthumb <size> [color]
shapeshape circle | square | diamond | pill | bar
ringring <width> <color>
glowglow <color> <radius>
ticksticks <count> [color] [length]
graphgraph <v1> <v2> ... (each 0 to 100)
graph-heightgraph-height <px>
graph-gapgraph-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.

glass-and-tilt.dssl
.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%)
}
Frostedglass: 18 saturate 1.6
Follows youtilt: 10 lift 1.04 glare

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.

settingwhat it does
blura number: how many px to blur what is behind the element (default 16)
saturatesaturate <n>: colour boost for what shows through, 1 is unchanged (default 1.5)
brightbright <n>: brightness of what shows through, 1 is unchanged
tinttint <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.

settingwhat it does
maxa number: the largest tilt in degrees (default 8)
liftlift <n>: scale while the pointer is over it, e.g. 1.04
perspectiveperspective <px>: how strong the 3D depth looks (default 900)
speedspeed <0.05 to 1>: how quickly it follows (default 0.18)
invertinvert: tilt away from the pointer instead of toward it
glareglare: 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.dssl
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.

console
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-inspect

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

terminal
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 --inspect

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

terminal
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
promisehow it is checked
JavaScript off, first paint is rightevery 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 CSSa 2,000-card page against hand-written CSS: startup +0%, churn +7% (0% with --inline-tokens), restyle +20% (0% inlined)
The engine itselfnot at parity: load event 17x, churn 4x. A built page does not pay that
Strict CSPthe 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 renderingpublic/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).

terminal
node cli/dssl.mjs import styles.css -o styles.dssl    # CSS to DSSL, and a report of what could not carry over

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

typo.dssl
.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"
}
Escape hatches are explicit. Custom properties (--*) 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.

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

CallWhat 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:

settle.js
// 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.

freeze.js
// 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 again
Frozen means frozen. While frozen, the page is a snapshot: hover styles, seen 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.

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

Why the notice happens. The engine applies styles to the SSR DOM before React hydrates, that is the whole anti-flash design. React then compares the DOM against its virtual render, finds extra 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.

dev-only console shim (this site's layout.tsx)
// 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.