Docs
Effects
A token is a value. A component is a thing. An effect is a verb — a behavior applied to an element under a condition — and neither of the other two can hold one.
The shape
A token cannot say "on hover"; a component cannot decorate an element that already exists without wrapping it. So an effect is addressed the way an icon name and a theme recipe already are: a name that is its own spec.
<span data-fx="throb"> <!-- no condition: ambient -->
<a data-fx="glow@hover">
<button data-fx="glare@hover lift@active">
<div data-fx="tint@checked frost@disabled">
<!-- re-point one effect's color inline, like the toolbar above does -->
<a data-fx="glow@hover" style="--fx-color: var(--pink)">
<!-- named parameters, any subset, any order -->
<span data-fx="throb?rate:3s,colors:[accent,accent-2]">
<div data-fx="glow@hover?spread:18">
The library
Each effect gets the stage that actually shows it, because one shared stage cannot show them
all. A halo is light spilling onto its surroundings, so glow and throb
need a dark surface and no clipping. A filter acts on the element's own content, so
saturate needs color inside the target; a backdrop-filter acts
on what is behind, so frost needs the opposite. The spec under each tile is the one
it is running, parameters and all.
glow@hover?spread:18
a halo, the accent bloom
throb?rate:5s,colors:[accent,accent-2],spread:20
a slow pulse that drifts between the two accent hues
glare@hover?angle:105,rate:900ms
an animated sheen that sweeps across the box
lift@hover?distance:7
a small rise with a shadow under it
tint@hover?amount:55,color:accent
a color wash over the surface
frost@hover?blur:7,saturation:90
a backdrop blur and saturation bump
reveal?distance:14,rate:400ms
fades and slides in once armed
shake@hover?distance:6
a short nudge, for an error
flash@fired?amount:80
a hard blink of color: impact, rejection, that one
float@fired?distance:34,rate:900ms
a rise and fade out; a readout that appears, travels, and dies
pop@fired?scale:0.3
a scale in past the resting size and back; something arriving
wobble@fired?angle:9
a rotational jitter, where shake is a translational one
saturate@hover?amount:200 · switch → saturate@armed?amount:0
hover boosts to 200; the switch overrides to grayscale, hover and all
Intensity is the algorithm's policy, not a per-component guess. Switch the theme in the
toolbar and the whole layer follows; on xtyle-hc it flattens to nothing, because a
halo, a lift, and a wash all spend the edge contrast a high-contrast taste exists to protect.
Parameters
An effect's tunables are named, after a ?:
throb?rate:3s,colors:[accent,accent-2], glare@hover?angle:315,rate:100ms. A bare number takes
the parameter's own unit (so 315 is degrees and 3 is seconds), a hex is a
fixed color, and a bare name resolves to that theme token — which keeps it theme-reactive rather
than freezing whatever color it happened to be when you typed it.
glow@hover?spread:20,color:pink
throb?rate:1.2s,colors:[green,blue]
glare@hover?angle:315,rate:700ms,color:#ffffff
lift@hover?distance:10,color:purple
tint@hover?amount:60,color:red
| Effect | Parameters |
|---|---|
glow | spread (px), color |
throb | rate (s), color, color-alt, colors, spread (px) |
glare | angle (deg), rate (ms), color |
lift | distance (px), color |
tint | amount (%), color |
frost | blur (px), saturation (%) |
reveal | distance (px), rate (ms) |
shake | distance (px), rate (ms) |
flash | color, amount (%), rate (ms) |
float | distance (px), rate (ms) |
pop | scale, rate (ms) |
wobble | angle (deg), rate (ms) |
saturate | amount (%) |
Conditions
| Condition | Selector |
|---|---|
| none | always — ambient |
hover | :hover |
focus | :focus-visible |
active | :active |
checked | :checked |
disabled | :disabled,[aria-disabled="true"] |
open | [open],[aria-expanded="true"] |
invalid | :invalid,[aria-invalid="true"] |
armed | [data-fx-armed] |
selected | [aria-selected="true"],[data-selected] |
current | [aria-current],[data-current] |
busy | [aria-busy="true"],[data-busy] |
fired | [data-fx-fired] |
The optional runtime
The layer is a stylesheet and needs nothing running. But a parameter cannot live in a
selector, so writing the attribute by hand gets you the effect and the defaults; the
?… tail is understood and then dropped. applyEffect is the pairing done
correctly, and it clears only the properties it wrote, so swapping specs can't leave a stale
parameter retuning the next one.
// the effect fires, and its parameters are silently dropped
el.dataset.fx = "throb?rate:1.4s";
// both halves, together
applyEffect(el, "throb?rate:1.4s");
A transient (flash, float, pop,
wobble, shake) is a verb about an event rather than a state, so it needs
a trigger. fireEffect sets the fired condition and clears it once the
animation lands; a call that arrives mid-flight restarts it rather than being swallowed.
import { applyEffect, fireEffect, armInView } from "@xtyle/core/fx";
// set the spec AND write the properties its parameters resolve to
applyEffect(el, "glow@hover?spread:20,color:danger");
// fire a transient: sets `data-fx-fired`, clears it when the animation lands
await fireEffect(toast, "pop@fired");
// arm every `reveal` under a root, disarming each as it scrolls in
const stop = armInView(document.body);
Tokens
| Token | Derived from |
|---|---|
--fx-intensity | the algorithm's taste; 0 disables the whole layer |
--fx-color | --accent |
--fx-color-alt | --accent-2 — the hue a throb travels toward |
--fx-duration | --duration-base |
--fx-ease | --ease-standard |
These are ordinary tokens on the open register, so a theme dials them with an override and needs no knob. Four shared tokens rather than a family per effect is deliberate: a per-effect family would grow with the library and lock a third-party effect out of deriving at all, whereas a shared dial means an addon's effect answers to the theme the day it lands.
Adding your own
The registries are last-wins on the name, and the built-ins register first — so an addon replaces one entry or adds a new one without restating the rest. Same contract fills use.
import { registerEffect, registerCondition } from "@xtyle/core";
// last-wins on the name: this replaces the shipped glow
registerEffect({ name: "glow", active: "filter:blur(1px)" });
// ...and this adds one the library never had
registerEffect({ name: "sparkle", active: "outline:1px dashed var(--fx-color)" });
registerCondition({ name: "dragging", selector: "[data-dragging]" });
// `sparkle@dragging` now works — and so does `glow@dragging`, because the
// cross product is regenerated, not hand-listed.
Reduced motion
Every effect that moves is wrapped in a prefers-reduced-motion: reduce guard that
returns it to stillness. This is a large part of why the layer is worth having: the policy is
decided once, correctly, instead of by every adopter who happens to remember.