Self-contained interactive figure: a column of controls (sliders, dropdowns, toggles, color pickers) drives a live preview whose CSS reads each control's value as a custom property. Optional chips below the preview echo a single param's value or evaluate a JS expression over the param env.
Provides its own outer card — do not wrap in <Figure>.
import ParamPlayground from '../../../components/figures/param-playground/ParamPlayground.astro';
import Param from '../../../components/figures/param-playground/Param.astro';
import Preview from '../../../components/figures/param-playground/Preview.astro';
import Readout from '../../../components/figures/param-playground/Readout.astro';(Relative to a lesson at src/content/docs/<chapter>/<lesson>.mdx.)
| Prop | Type | Required | Default | Purpose |
|---|---|---|---|---|
caption |
string |
no | — | Small caption rendered above the controls/preview grid. Plain text only. |
| Prop | Type | Required | Default | Purpose |
|---|---|---|---|---|
id |
string |
yes | — | Identifier the preview reads as var(--<id>) and that <Readout of=…> / <Readout expr=…> reference. Must be a valid CSS custom-property suffix and a valid JS identifier. |
kind |
'slider' | 'select' | 'toggle' | 'color' |
yes | — | Control type. See below. |
label |
string |
yes | — | Plain-text label shown above the control. |
default |
number | string | boolean |
no | first option / min / false / '#3b82f6' |
Initial value. Sliders clamp to [min, max]; selects fall back to the first option when the value isn't in options. |
min |
number |
no (slider) | 0 |
Slider lower bound. |
max |
number |
no (slider) | 100 |
Slider upper bound. |
step |
number |
no (slider) | 1 |
Slider step. |
suffix |
string |
no (slider) | '' |
Unit appended to the CSS variable. With suffix="px", var(--width) resolves to e.g. 220px — directly usable in width: var(--width). The raw numeric value (without suffix) is what <Readout expr=…> sees. |
options |
string[] | { value: string; label?: string }[] |
yes (select) | [] |
Select options. |
on |
string |
no (toggle) | 'true' |
Value emitted when the toggle is checked. |
off |
string |
no (toggle) | 'false' |
Value emitted when the toggle is unchecked. |
No props. Default slot is the preview content. Each <Param id="…"> exposes var(--<id>) on the preview wrapper — the raw value with the param's suffix appended (e.g. 220px, oklch(0.62 0.18 210), border-box, #3b82f6). Use it in any CSS property: width: var(--width), background: var(--brand), box-sizing: var(--boxSizing).
| Prop | Type | Required | Default | Purpose |
|---|---|---|---|---|
label |
string |
yes | — | Plain-text label rendered as the chip's caption. |
of |
string |
one of of / expr |
— | Echo this param's current value (with optional suffix). |
expr |
string |
one of of / expr |
— | JS expression evaluated against the param env. Numeric params are numbers, others are strings. Example: boxSizing === 'border-box' ? width : width + padding * 2. |
suffix |
string |
no | '' |
Appended to the rendered value. |
Inside <Readout expr="…"> these names are also in scope:
| Name | Signature | Purpose |
|---|---|---|
wcagContrast |
(a: string, b: string) => number |
WCAG 2.1 relative-luminance contrast ratio between two CSS colors. Accepts any color string the browser can resolve (#hex, oklch(...), rgb(...), named colors, etc.). Returns the rounded ratio. |
wcagPasses |
(a, b, level?: 'AA' | 'AAA', size?: 'normal' | 'large') => boolean |
true when the ratio meets level (default 'AA') at size (default 'normal'). Thresholds: AA 4.5 / AA-large 3 / AAA 7 / AAA-large 4.5. |
Math |
global | Standard JS Math — useful for Math.round, Math.min, etc. |
ParamPlaygrounddefault — any mix of<Param>,<Preview>, and<Readout>children, in any order. The script reorganizes them at runtime into the controls column, preview wrapper, and readouts row.Previewdefault — the preview content. Plain HTML / MDX / components. Read params viavar(--<id>)in inlinestyleor in a<style>block scoped to the preview.
<ParamPlayground>
<Param id="hue" kind="slider" label="Hue" min={0} max={360} default={210} />
<Param id="dark" kind="toggle" label="Dark" on="dark" off="light" default={false} />
<Preview>
<div style="background: oklch(0.7 0.15 var(--hue));">…</div>
</Preview>
<Readout label="Hue" of="hue" />
<Readout label="CSS" expr="'oklch(0.7 0.15 ' + hue + ')'" />
</ParamPlayground><Param>,<Preview>, and<Readout>must be direct children of<ParamPlayground>. The script finds them via[data-pp-param],[data-pp-preview], and[data-pp-readout]; deeper nesting is invisible to it.<Param id>must be unique within a single<ParamPlayground>— duplicate IDs collide on the same CSS custom property and the same key in the readout env.- Slider values pipe to CSS with the
suffixbaked in. Reach forsuffix="px"(ordeg,%,rem,ms, …) so the preview can writewidth: var(--width)rather thanwidth: calc(var(--width) * 1px). <Readout expr>is evaluated withnew Function. Author content is trusted (course MDX). A syntax error or runtime throw renders—in the chip and surfaces the error message ondata-pp-readout-errorfor debugging.- This component is a complete figure card — don't wrap it in
<Figure>(you'll get nested padding and borders). - The component renders no preview frame chrome — no fake browser bar, no width drag handle, no class-context toggle. That's
PreviewFrame's job (a separate component, not yet built).
- A senior decision is best taught by manipulating it: token values, OKLCH channels, layout numerics, ICU plural counts, viewport widths.
- A static
Figurewould freeze the comparison the student needs to feel by sliding through. The proposals report flagsParamPlaygroundas "the single biggest expressiveness gap in the toolkit" — when nothing else carries live cause-and-effect, this is the primitive. - The preview's reactions can be expressed in pure CSS. If the lesson needs JS branching inside the preview (e.g.,
URL.canParse(input)succeeds/fails styling), wait for the React-props injection path or fall back toReactCoding.
A box-model sizer that exercises slider + select + computed expr readouts:
<ParamPlayground>
<Param id="width" kind="slider" label="Width" min={80} max={360} default={220} suffix="px" />
<Param id="padding" kind="slider" label="Padding" min={0} max={48} default={16} suffix="px" />
<Param id="border" kind="slider" label="Border" min={0} max={12} default={4} suffix="px" />
<Param id="boxSizing" kind="select" label="Box sizing" options={['content-box', 'border-box']} default="border-box" />
<Preview>
<div
style="
width: var(--width);
padding: var(--padding);
border: var(--border) solid hsl(217, 91%, 60%);
background: hsl(217, 91%, 95%);
box-sizing: var(--boxSizing);
"
>
box
</div>
</Preview>
<Readout label="Declared" of="width" suffix="px" />
<Readout
label="Rendered outer"
expr="boxSizing === 'border-box' ? width : width + padding * 2 + border * 2"
suffix="px"
/>
</ParamPlayground>