7.8 KiB
Cascade
A thin explicit orchestrator that staggers its children's enter/exit. It owns no recipe
and no timing of its own — it rides the foundation stagger + the existing state-presets. It
is the explicit / content-animation way to sequence a set of elements (you designed them to
stagger), as opposed to the event-driven path where a component's own emerge firma + the
same foundation stagger do it (see "Two ways" below).
<script>
import { Cascade } from '$uix/eidos/components/cascade'
let open = $state(true)
</script>
<Cascade {open} motion="scale-fade" style="display: grid; gap: 0.75rem; --motion-stagger-each: 60ms">
{#each items as item (item.id)}
<Cascade.Item>{item.label}</Cascade.Item>
{/each}
</Cascade>
--motion-stagger-each: 0 (the default) → parallel; N → cascade. The keyframe,
duration, easing and reduced-motion all come from the motion preset.
Baseline
No Air baseline — new component (2026-06). The closest prior art in the repo
was the ad-hoc per-item transition-delay hand-rolled by early menu demos;
Cascade replaces that with the foundation stagger mechanism.
Comparativa
| Reference | Their shape | Difference |
|---|---|---|
Framer Motion (staggerChildren) |
JS orchestration prop on a motion parent; the library owns the timeline | Cascade is CSS-only: the stagger index is structural (:nth-child → --motion-stagger-index), the animation is the child's own state preset — no JS timeline, SSR-safe |
Motion One / GSAP stagger() |
Imperative tween over a NodeList | Same difference — Cascade never enumerates children in JS; removed nodes are retained only for their exit |
| Radix / Ark / Bits | No stagger-container primitive | A gap they leave to userland; Cascade makes it a composable part |
M3 (Compose) AnimatedVisibility + manual delays |
Per-child delay set by hand | Cascade derives the delay from structure; reordering children never desyncs the wave |
How it works — it reuses everything
Nothing here is a new system. The cascade is the existing declared motion plus one foundation rule:
| Piece | Where it's declared / derived |
|---|---|
| the per-item animation (keyframe, duration, ease, reduced-motion) | a state-preset declared in EidosConfig.motion (fade / scale-fade / …), generated by eidos to generated/base.css and registered with uix.motion |
the stagger delay (index × --motion-stagger-each, default 0 = parallel) |
already in every preset's enter/exit rule (lib/render-css.ts) |
the structural index (--motion-stagger-index / -rev) |
the one new foundation rule: [data-stagger] > *:nth-child / :nth-last-child (generated by lib/render-css.ts) — nobody writes it, soma never writes a visual var |
<Cascade> just wires those: it marks the container [data-stagger], reflects open as
data-state, and passes open + the chosen preset to its items (context), so each item
carries data-animation-style + data-state. Enter counts up (:nth-child); exit counts
down (:nth-last-child) so the last item leaves first.
Props
<Cascade>
| Prop | Type | Default | Notes |
|---|---|---|---|
open |
boolean |
true |
Drives the staggered enter (forward) / exit (reverse) via data-state. |
motion |
MotionPresetName |
'fade' |
The state-preset every item plays (fade / scale-fade / slide-fade / …). Unified with the overlays' selector (RFC §D.12). |
Plus any HTMLAttributes<HTMLDivElement>. Set the layout + the rhythm (--motion-stagger-each)
here.
<Cascade.Item>
A participant. On removal it flips to data-state='closed' (the existing exit preset
plays, with the reverse structural index), is retained (out:) for the eidos-declared exit
duration, then unmounts. Accepts any HTMLAttributes<HTMLDivElement>.
Ownership
| Owns | Never touches | |
|---|---|---|
| soma (the wrappers) | state (open → data-state, propagated to items) + lifecycle (out: retention) |
any --motion-* / visual variable — it reads the eidos duration via the ActiveDom getComputedStyle, writes nothing |
| eidos | all the visual — the preset (keyframe/duration/ease/reduced-motion) + the foundation stagger index | the semantics / state |
Two ways to stagger children
-
Explicit —
<Cascade>(this component). You want these elements to stagger; there is no semantic event. The content/intrinsic animation domain. -
Event-driven — a real component (a menu, a list) declares its own
emergefirma (the container flourish + sound) and marks its item container[data-stagger]; its items carry a preset (data-animation-style). Same foundation stagger, no<Cascade>wrapper — the appearance flows from the declared event. The container-driven preset rule[data-stagger][data-state='open'] > [data-animation-style]fires the children's enter off the container'sdata-state(which soma already writes via the morfocommits), so the items carry no per-itemdata-state— amenuitemisn't open/closed; the menu is. (The firma is generic by event: "children of an emerging container present together, by structural order"; the concrete timing — parallel/cascade, ms, keyframe — is the per-component realization.)First concrete consumer:
<DropdownMenu>— items fade in staggered on open (src/uix/eidos/components/dropdown-menu/). See its README → Motion. The container-driven rule is enter-only; the coordinated exit cascade (retain the container until its children finish) is the deferred somaPresenceGroupwork.
Known limitation — bulk removal
When several items are removed at once (e.g. a slider jumping 10 → 3), the leaving nodes
unmount one-by-one as each finishes, so the grid reflows during the cascade and the remaining
items' :nth-last-child index re-evaluates. In practice it reads fine, but the glitch-free
version wants the container to retain the leaving set as a unit (one reflow at the end) —
a soma lifecycle concern (what the retired pending did, without touching the visual).
Dragging the slider down step-by-step (a sequence of lone removals) is always clean.
Decisiones
- No recipe, no
--cascade-*namespace, no bespoke keyframe. An earlier prototype forked all of that — it reinvented the existing--motion-stagger-*+ state-preset system. This replaces it by reusing them; the only new code is the foundation structural-index writer. Cascadeis the EXPLICIT domain. Event-driven children appearance rides the firma + the same foundation stagger, declared in the component's own morfo — not this wrapper.- Index from
:nth-child, capped 1–24 (the foundation writer). Beyond the cap items share the last index untilsibling-index()is broadly supported.
Gaps
- No demo page yet (deferred to the demo/docs web phase) — disposition: implementar.
- Bulk removal reflows mid-cascade (see "Known limitation" above) — the fix is
the deferred soma
PresenceGroupretention — disposition: diferir. - No per-cascade rhythm prop; the consumer sets
--motion-stagger-eachon the container style — disposition: diferir (the var IS the API for now).
Passive justification
Zero events by design: Cascade is the stage for its children's state
animations — it declares no interaction, focus, or keyboard surface (the
children own theirs), and its own flourish, if any, belongs to a wrapping
component's emerge firma. scope: ['eidos'], events: [] in the morfo.
Audit exceptions
R-1.1 exception:Cascade deliberately ships no recipe — it rides the foundation's structural stagger index plus each child's state preset; a[data-cascade]rule would style nothing (see Decisiones and the note kept incascade.css).