LSD Framework
Actions
Behaviours · imperative layer

Wires. Triggers. Actions.

A wire in LSD is a single declarative line: "when this trigger fires on this element, run this action against this target." Triggers are the listening side (events, scrolls, timers, custom signals). Actions are the doing side — DOM mutations, state changes, animation controls, custom expressions. Both live in window.LSD at runtime and round-trip through the Behaviours panel in the editor.

Live · click to compose

Wire Lab

Pick a trigger, an action, a target. Hit fire. Watch the wire light up — every chip you select draws a real { trigger, action, target } object the Behaviours panel could serialize verbatim.

1 · Trigger
2 · Action
3 · Target
α0
β0
γ0
// pick a trigger, action and target above
00 · vocabulary

Cosmic naming, namespaced verbs

LSD names its layers after astronomical structures. Each name has a precise meaning — they are not interchangeable. Action kinds also follow a strict <namespace>:<verb> shape so the registry stays scannable as the action vocabulary grows.

Galaxy

The full token tree — every design token a project exposes (color, spacing, typography, motion, surfaces). Top of the LSD object graph.

Nebula

The OKLCH-based color model — semantic axes (temperature, constellation, role, contrastPeers) and the algorithms that derive ramps from a brand seed.

Cosmos

The brand-color system — palettes, surfaces, role mappings. Built on top of Nebula but framed for authors and AI agents who don't need to see the math.

Orion

The surface-ripple law — how a custom color token cascades through a derived surface stack (base / elevated / high tiers).

Constellation

A grouped relationship between color seeds — how Nebula's semantic axes wire palettes to one another.

Aurora

The keyframe + timeline engine — authored Sequence records that emit working CSS for time-driven, scroll-driven, and event-driven motion. Actions on this page can anim:play against Aurora handles.

Behaviours

The wire authoring surface — Trigger → Action → Target compositions, persisted as data-lsd-wire-{id} attributes and live through LSD.wires. Aurora is the motion side; Behaviours is the event-driven JS side.

Action kinds use a flat <ns>:<verb> form. The namespace is the noun (class, attr, state, anim); the verb is the imperative (toggle, set, play, inc). Bare verbs (no colon) are reserved for top-level primitives like emit, custom, and noop.

01 · triggers

When the wire fires

Triggers are stored as a string in wire.trigger.event. Most are plain DOM event names; a few (keys, timers, scroll, lsd:, aurora:) use a colon-suffixed form that the Behaviours panel parses into a two-input row.

DOM · pointer

click

Fires on a primary click (mouse button or activation via Enter/Space on a focused button).

"trigger": { "event": "click" }
fired ×0
DOM · pointer

dblclick

Fires on a double-click within the browser's dblclick interval.

"trigger": { "event": "dblclick" }
fired ×0
DOM · pointer

mouseenter / mouseleave

Hover in / hover out. Use over mouseover — these don't bubble through child elements.

"trigger": { "event": "mouseenter" }
hover the circle
DOM · focus

focus / blur

Fire when a focusable element receives/loses keyboard focus. Tab into the input below.

"trigger": { "event": "focus" }
blurred
DOM · form

submit / input / change

Form triggers. submit on a <form>, input on every keystroke, change on commit (blur or Enter).

"trigger": { "event": "input" }
length: 0
DOM · keyboard

keydown:<Key> / keyup:<Key>

Key suffix is event.key (matches the W3C name — Escape, Enter, ArrowUp, etc.). Wire targets document by binding on a parent.

"trigger": { "event": "keydown:Escape" }
Press Escape anywhere fired ×0
Viewport · scroll

scroll-into-view / scroll-out-of-view

An IntersectionObserver fires when the bound element enters or leaves the viewport. Used for reveal animations and lazy effects.

"trigger": { "event": "scroll-into-view" }
awaiting
scroll past me
Time

timer:<ms>

Fires once after ms milliseconds from wire registration. Useful for splash effects or auto-dismiss.

"trigger": { "event": "timer:2000" }
idle
Custom event

lsd:<custom-event>

Subscribe to a CustomEvent dispatched by another wire's emit action. The convention is lsd:<name> on the document.

"trigger": { "event": "lsd:cart-updated" }
heard ×0
Aurora event

aurora:<event-name>

A Sequence's authored events (e.g. halfway, done) bubble as lsd-aurora-event with event.detail.name. The editor canonicalises and stores both an event + auto-condition.

"trigger": {
  "event": "lsd-aurora-event",
  "condition": "event.detail.name === 'halfway'"
}
awaiting…
02 · actions

What a wire does when it fires

Every action is registered against LSD.action with a kind, declared params, and a run(ctx, params) function. The runtime resolves ctx.target from wire.action.target at fire time and hands the action a single element (or fans out across many for {kind:'all'}).

DOM mutations

action · class

class:toggle

Toggle a class on the resolved target. The most-used action in LSD by a wide margin.

params: { name: string }

{ "kind": "class:toggle",
  "params": { "name": "is-open" } }
action · class

class:add

Add a class. Idempotent — re-adding is a no-op. Pair with class:remove on a different trigger for explicit state machines.

params: { name: string }

{ "kind": "class:add",
  "params": { "name": "is-on" } }
action · class

class:remove

Remove a class. Inverse of class:add.

params: { name: string }

{ "kind": "class:remove",
  "params": { "name": "is-on" } }
action · attribute

attr:set

Set any HTML attribute. Empty / null values become an empty string (attribute present but blank). Pair with CSS attribute selectors for declarative state styling.

params: { name: string, value: string }

{ "kind": "attr:set",
  "params": { "name": "aria-pressed",
              "value": "true" } }
aria-pressed=false

Events

action · event

emit

Dispatch a CustomEvent from the resolved target. By convention the runtime prefixes with lsd: when picked up by an lsd:<name> trigger. detail can be any JSON value.

params: { name: string, detail?: any }

{ "kind": "emit",
  "params": { "name": "cart-updated",
              "detail": { "items": 3 } } }
latest detail: —

Parent → children · cascade

One wire, N children. The cascade action runs a class/attr op against each child matching selector with a staggered delay — origin can flow from the first, last, the center out, the edges in, or random. Authors get bento-reveal, stagger-out, ripple, and dealer-shuffle as one composable primitive. Each child gets --lsd-cascade-index, --lsd-cascade-step, and --lsd-cascade-total as CSS vars so transitions stay declarative.

Bento reveal · live

Pick origin + op + stagger, hit fire. Resets between runs.

from
op
stagger 80ms
{ "kind": "cascade",
  "params": { "op": "class:add", "class": "is-revealed",
              "from": "first", "stagger": 80 },
  "target": { "kind": "self" } }
action · cascade

cascade

Run an op against each child matching selector, staggered. The composable parent-as-controller primitive — one wire fans out across N children.

params: { op, class, selector?, stagger?, from? }

{ "kind": "cascade",
  "params": { "op": "class:add",
              "class": "is-in",
              "selector": ":scope > *",
              "stagger": 80, "from": "first" } }

State (LSD.state)

action · state

state:set

Write a value to LSD.state[key]. Triggers any data-bind-* bindings that depend on the key.

params: { key: string, value: any }

{ "kind": "state:set",
  "params": { "key": "cart.count", "value": 0 } }
action · state

state:toggle

Flip a boolean state key. Treats undefined / falsy as false.

params: { key: string }

{ "kind": "state:toggle",
  "params": { "key": "modal.open" } }
false
action · state

state:inc / state:dec

Add (or subtract) by to a numeric state key. Default step is 1.

params: { key: string, by?: number }

{ "kind": "state:inc",
  "params": { "key": "score", "by": 1 } }
0
action · state

state:reset

Reset a state key to its registered initial value (or remove it from the store when it was never declared).

params: { key: string }

{ "kind": "state:reset",
  "params": { "key": "score" } }
0

Aurora animation

These actions operate on animation handles registered with LSD.anim.assign(el, handle, opts). Target shape is { kind: 'anim', handle: 'name' } — there is no DOM lookup; the handle is the resolution.

action · anim

anim:play

Start (or resume) a paused animation handle. Re-playing a finished animation restarts it from progress 0.

params: { handle?: string }

{ "kind": "anim:play",
  "target": { "kind": "anim", "handle": "pulse" } }
action · anim

anim:pause

Pause a running animation at its current progress.

params: { handle?: string }

{ "kind": "anim:pause",
  "target": { "kind": "anim", "handle": "pulse" } }
action · anim

anim:toggle

Play if paused, pause if playing. Convenience for play/pause toggles.

params: { handle?: string }

{ "kind": "anim:toggle",
  "target": { "kind": "anim", "handle": "pulse" } }
action · anim

anim:seek

Jump the playhead to progress in [0, 1]. Useful for scrubbing or resetting (seek 0 + play = restart).

params: { handle?: string, progress: number }

{ "kind": "anim:seek",
  "params": { "progress": 0 },
  "target": { "kind": "anim", "handle": "pulse" } }

Escape hatches

action · escape hatch

custom

Sandboxed JS. Single-line expressions run in Tier 2 (no window/document access). Multi-line / semicolon snippets require allowUntrusted: true — the editor's trust toggle sets this explicitly.

params: { snippet: string, allowUntrusted?: boolean }

{ "kind": "custom",
  "params": { "snippet":
    "state.count = (state.count||0) + 1" } }
action · escape hatch

alpine:expr

Hand the expression to AlpineJS via Alpine.evaluate(trigger, expr) when Alpine is loaded; no-op otherwise. The adapter compiles x-on: directives into this when they don't map to a known LSD action.

params: { expr: string }

{ "kind": "alpine:expr",
  "params": { "expr": "open = !open" } }
action · escape hatch

noop

Does nothing. Useful as a placeholder while authoring, or when an Alpine adapter compiles an empty directive.

params: {}

{ "kind": "noop" }
03 · targets

Where the action lands

An action's target tells the runtime which element to operate on. When omitted, the trigger element is the target. Beyond the six kinds below, targets can carry a { kind: 'anim', handle } shape — used by the Aurora animation actions to bypass the DOM resolver entirely.

Selector resolver · live

0 matches

try these

#hero .bunny ready .bunny sleepy .bunny ready section
  • .item 1
  • .item 2 stale
  • .item 3 .tag
  • .item 4
target · default

self

The element that hosts the wire (ctx.trigger). The implicit default when target is omitted.

"target": { "kind": "self" }
// or simply omit the target.
target · ancestor

nearest

Walks up the DOM via closest(selector). Use for "the card I'm inside of" or "the form wrapping this button."

"target": { "kind": "nearest",
            "selector": ".card" }
target · sibling

sibling

Walks up to the trigger's parent, then queries downward for the first match (skipping the trigger itself).

"target": { "kind": "sibling",
            "selector": ".panel" }
target · global

query

document.querySelector(selector) — first match anywhere in the document.

"target": { "kind": "query",
            "selector": "#cart-drawer" }
target · global · fan-out

all

document.querySelectorAll(selector) — runs the action once per match. The only target kind that fans out.

"target": { "kind": "all",
            "selector": ".accordion-panel" }
target · global · indexed

by-id

Resolves through document.getElementById. Compiled from "#anchor" strings by the editor — preferred when you know the id ahead of time.

"target": { "kind": "by-id",
            "id": "checkout-modal" }
04 · tutorials

Wires in full — composed examples

Each tutorial below walks from blank HTML to a working interaction: the markup you author, the wire the Behaviours panel saves, and what the user sees at runtime. Read top-to-bottom — every step builds on the one above it.

T1 · Toggle a modal

  1. Author the markup

    Two elements: a trigger (the button) and a target (the modal). The modal carries the id the wire will resolve against; the is-open class is what we flip.

    <button class="btn" id="open-modal">Open modal</button>
    
    <div class="modal-backdrop" id="site-modal" role="dialog" aria-modal="true">
      <div class="modal-card">
        <h3>It works.</h3>
        <button class="btn btn--ghost" id="close-modal">Close</button>
      </div>
    </div>
  2. Save the wire

    In the Behaviours panel, select the button and add this wire. The wire lives on the button — its trigger.host is the button itself, and target points at the modal by id.

    {
      "trigger": { "event": "click" },
      "action": {
        "kind": "class:toggle",
        "params": { "name": "is-open" },
        "target": { "kind": "by-id", "id": "site-modal" }
      }
    }

    Why by-id and not self? The action runs against the modal, not the button — and you want the wire to keep working even if the button moves somewhere else in the tree.

  3. Try it

    Click the button below. The modal toggles. Click outside the card or press Esc to close (those are two more wires — see Variations).

    trigger button target #tut-modal

Variations
  • Close on Escape. Add a second wire whose host is the document — same target, different trigger.
    {
      "trigger": { "event": "keydown", "key": "Escape" },
      "action": {
        "kind": "class:remove",
        "params": { "name": "is-open" },
        "target": { "kind": "by-id", "id": "site-modal" }
      }
    }
  • Close on backdrop click. Put a third wire on the backdrop itself with target: { kind: 'self' } and action: 'class:remove'.
  • Track open count. Add a sibling action on the same trigger: state:inc with key modal.opens. Two actions, one trigger.

T2 · Reveal on scroll-into-view

  1. Author the markup

    Just one element. The block is the trigger and is the target. The reveal class (is-revealed) is whatever your CSS already knows how to animate.

    <div class="scroll-canary" id="hero-reveal">reveal me</div>
  2. Save the wire

    Select the block in Behaviours and add the wire. The wire lives on the block; target: 'self' means the action mutates the same element the trigger fires on. timing.once tells the runtime to detach after the first hit.

    {
      "trigger": { "event": "scroll-into-view", "threshold": 0.4 },
      "action": {
        "kind": "class:add",
        "params": { "name": "is-revealed" },
        "target": { "kind": "self" }
      },
      "timing": { "once": true }
    }

    The motion itself lives in CSS — the wire only stamps the class. This keeps the runtime cheap and lets designers tune the curve without touching JS.

  3. Try it

    Scroll this card until the block crosses 40% of the viewport. It flips to "revealed" once — and stays revealed even if you scroll it back out.

    reveal me

    trigger target same element

Variations
  • Re-trigger every entry. Drop timing.once and swap class:add for class:toggle — the block animates in on enter, out on leave.
  • Staggered children. Put the wire on a parent and target { kind: 'all', selector: '> *' } with a CSS transition-delay: calc(var(--i) * 80ms).
  • Play an Aurora sequence instead. Swap the action for anim:play against a registered handle — see T4.

T3 · Increment a counter on submit

  1. Author the markup

    The form is the trigger host. The display element doesn't need a wire at all — it uses data-bind-text to read straight from state, so it updates whenever the key changes.

    <form id="signup-form">
      <input name="email" placeholder="your email"/>
      <button type="submit" class="btn">Submit</button>
    </form>
    
    <span class="pill-state" data-bind-text="submissions">0</span>
  2. Save the wire

    Select the form and add this wire. Notice there's no target — state actions don't read the DOM, they mutate a key in LSD.state. The binding on the pill subscribes itself.

    {
      "trigger": { "event": "submit", "preventDefault": true },
      "action": {
        "kind": "state:inc",
        "params": { "key": "submissions", "by": 1 }
      }
    }

    This is the key shift from DOM-first thinking: the form doesn't know about the pill, and the pill doesn't know about the form. Both know about the same key.

  3. Try it

    Submit the form. The counter ticks up. Submit it again — the pill reads from state, so it stays in sync without any wire pointing at it.

    0

    trigger form bound state.submissions

Variations
  • Reset on a button. A second wire on a Reset button: action: 'state:set', params { key: 'submissions', value: 0 }.
  • Conditional submit count. Add a when guard: only count if the input value is non-empty. "when": "form.email.value.length > 0".
  • Persist across reloads. Add the persist: 'local' flag on the state key — the counter survives a refresh.

T4 · Play an Aurora sequence on hover

  1. Author the markup

    Just one element. Aurora keeps its own registry of animation handles; the element doesn't need a special attribute — the handle is the address.

    <div class="ring-target" id="hero-ring"></div>

    Elsewhere, your Aurora panel has registered ring-pulse via LSD.anim.assign(el, 'ring-pulse', { keyframes, duration, … }).

  2. Save the wire

    The trigger lives on the ring, but the target is not a DOM lookup — it's an Aurora handle. The runtime calls play() on the registered animation.

    {
      "trigger": { "event": "mouseenter" },
      "action": {
        "kind": "anim:play",
        "target": { "kind": "anim", "handle": "ring-pulse" }
      }
    }

    The { kind: 'anim' } target shape is the bridge between Behaviours and Aurora — anywhere a wire takes a target, an animation handle works.

  3. Try it

    Hover the circle below. Each enter restarts the pulse from frame zero (a paired anim:seek with progress 0 resets before play).

    hover the circle

    trigger ring target anim:ring-pulse

Variations
  • Reverse on leave. Add a sibling wire on mouseleave with action anim:reverse against the same handle.
  • Scrub on scroll. Swap the trigger to scroll-progress and the action to anim:seek — Aurora becomes a scroll-linked animation.
  • Group play. Target { kind: 'anim-group', tag: 'hero' } to fire every handle tagged hero in lockstep.