/ component

Transition + Animation Editor

Edit the CSS transition AND animation shorthands — both comma-separated layer lists — with compile-time PER-LAYER TOKEN-KIND CLASSIFICATION. A mode prop picks the shorthand. The list splits by comma into layers, each layer splits by space into tokens, and every token is classified by KIND with a per-kind cardinality cap (tokens are order-independent within a layer). Transition layers: ≤2 <time> (duration, delay), ≤1 <easing-function>, ≤1 <property>, ≤1 allow-discrete. Animation layers: ≤2 <time>, ≤1 easing, ≤1 iteration-count, ≤1 direction, ≤1 fill-mode, ≤1 play-state, ≤1 <keyframes-name>. The <easing-function> token reuses easing-picker's EasingLiteral. opacity 200ms 100ms 50ms ease → never (3 times); spin 1s 2 3 → never (2 iteration counts). The strict tier resolves any violation to never; the runtime parser is tolerant.

/ basic-usage

Two shorthands, one editor

Controlled value + onChange. The mode prop switches between the transition and animation shorthands. Add layers, edit each token with the right control, and the editor emits a valid comma-separated string.

opacity 200ms ease, transform 0.3s 100ms ease-out
spin 1s linear infinite
/ live-preview

Watch it actually move

Each panel carries a real preview element with the built value applied. The play button fires the transition; the replay button restarts the animation. Pick each layer's easing with the embedded EasingPicker, scrub the duration with the ms control, and every change writes back the typed string. Animation mode ships demo @keyframes (slide / pulse / spin).

transition
transform 400ms 0ms ease-in-out
preview
duration
transform 400ms 0ms ease-in-out
animation
1.2s ease-in-out infinite alternate pulse
preview
duration
pulse 1.2s ease-in-out infinite alternate
/ types

Three usage tiers

From useState-and-go to per-layer token-kind validation.

01 casual
string

Pass any string

useState<string>. No compile-time validation; the runtime parser classifies whatever you type by token kind — including calc() durations and tokens in any order.

opacity 200ms ease
const [value, setValue] = useState<string>("opacity 200ms ease")
02 intellisense
TransitionString

Literal-shaped hints

State typed as TransitionString — a space-separated layer, a comma-joined list, or none. The mode keys the map: AnimationString is the animation-mode return. It is also the onChange type.

opacity 200ms ease
const [value, setValue] = useState<TransitionString>("opacity 200ms ease")
03 strict
TransitionLiteral<S>

Per-layer token-kind typing at compile time

cssTransition() / cssAnimation() split the list by comma, then each layer by space, classify every token by kind, and resolve excess cardinality, an unknown token, or a bad easing to never — a type error before you run the code.

opacity 200ms 100ms ease-in-out allow-discrete
spin 1s ease-in-out infinite alternate
cssTransition("opacity 200ms 100ms ease allow-discrete") // ✓
// @ts-expect-error three <time> tokens
cssTransition("opacity 200ms 100ms 50ms ease")
// @ts-expect-error two iteration counts
cssAnimation("spin 1s 2 3")

Note: calc() / var() tokens are rejected at the strict tier — use the casual or IntelliSense tier; the runtime parser accepts them.

/ api

API

Public surface — component props, runtime helpers, and the type exports.

§ TransitionEditor / TransitionEditorPanel

<TransitionEditor
  mode?: "transition" | "animation"   // default "transition"
  value: TransitionString | AnimationString | (string & {})
  onChange: (next) => void              // return type keyed by mode
  className?: string
  aria-label?: string
/>

TransitionEditor is popover-wrapped; TransitionEditorPanel renders the same editor inline. Both are controlled and generic on mode.

PropTypeDescription
mode"transition" | "animation"Which shorthand to edit. Defaults to `transition`. Narrows the onChange return type via TransitionEditorStringMap.
valueTransitionString | AnimationString | (string & {})Current CSS value. Required. `none` is the empty state.
onChange(next: TransitionEditorStringMap[Mode]) => voidFires when the edited layer list changes. Emits the canonical comma-separated string or `none`.

§ Sub-components

<TransitionLayerRow mode layer onChange onRemove index? />

One editable layer. In transition mode: a property input (with a datalist), duration / delay TimeFields, an embedded EasingPicker, and an allow-discrete toggle. In animation mode: a name input, duration / delay, EasingPicker, an iteration-count input with an ∞ toggle, and direction / fill-mode / play-state selects.

<TimeField label value onChange />

A <time> editor: a numeric field plus an ms/s unit select; raw text passthrough for opaque calc()/var().

<KeywordSelect label value options onChange />

A labelled native select with an empty (unset) option — backs direction / fill-mode / play-state.

<AddLayerButton onAdd />

Appends a fresh layer (mode-appropriate default) via onAdd.

<TransitionPreview mode value onChange? />

The live showcase: a target element with the built transition / animation applied, a play (transition) / replay (animation) button, shipped demo @keyframes, and a UnitInput duration scrubber for the first layer.

§ Runtime helpers

cssTransition<S>(value: S & TransitionLiteral<S>): S

Call-site validator for `transition`. Mirrors cssBoxShadow() / color() / easing().

cssAnimation<S>(value: S & AnimationLiteral<S>): S

Call-site validator for `animation`.

parseTransition(src): TransitionLayer[] | null

String → typed layers, or null on excess cardinality / unknown token / syntax error. `none` and empty → []. Tolerates calc()/var().

parseAnimation(src): AnimationLayer[] | null

The animation-mode parser, same contract.

formatTransition(layers) / formatAnimation(layers): string

Canonical re-serialization (deterministic token order; layers joined by `, `). Empty → `none`.

layerCount(mode, src): number

Runtime mirror of LayerCountOf — the number of layers (invalid → 0).

defaultTransitionLayer() / defaultAnimationLayer()

Seed a fresh layer (all 200ms ease / slide 1s ease 1).

§ Types

TransitionLiteral<S> / AnimationLiteral<S>
Strict validators — S if every layer's tokens classify within their per-kind caps, else never.
TransitionLayerLiteral<S> / AnimationLayerLiteral<S>
Strict single-layer validators (a comma-list is not a single layer).
TransitionString / AnimationString
Suggestion unions: a space-separated layer, a comma-joined list, or `none`.
TransitionEditorStringMap / EditorMode
Mode → output-string map (transition / animation) and its key union.
LayersOf<S> / LayerCountOf<S>
Tuple of the raw per-layer strings; and its length.
TransitionPropertiesOf<S> / AnimationNamesOf<S>
The <custom-ident> slot of each layer (property / keyframes-name).
TransitionLayer / AnimationLayer
Per-layer record state. Exported for advanced use.
TransitionEditorState
Discriminated union on `mode` — { mode; layers } for the internal editor state.

§ Strict-tier scope

  • Full per-layer token-kind classification: SplitByComma → per-layer SplitBySpace → each token folded into a per-kind counter. Tokens are order-independent within a layer (CSS classifies by kind, not position).
  • Transition caps: ≤2 <time> (first = duration, second = delay), ≤1 <easing-function> (validated via the easing-picker's EasingLiteral), ≤1 <property> (all / none / a weak <custom-ident>), and ≤1 allow-discrete.
  • Animation caps: ≤2 <time>, ≤1 easing, ≤1 iteration-count (<number> / infinite), ≤1 direction, ≤1 fill-mode, ≤1 play-state, and ≤1 <keyframes-name>.
  • Easing keywords (ease, linear, …) classify as the easing token, not the <custom-ident> slot — the real CSS interpretation. A bare none in animation classifies as the fill-mode. Both are deterministic, documented calls.
  • The <custom-ident> slot is weak-validated (non-empty, ident-safe chars, no leading digit). The full grammar is deferred to the runtime parser.
  • calc() / var() inside a token resolve to never (undecidable at compile time). The runtime parser accepts them.
  • The comma layer-list recursion is capped at 32 layers (the tail is weak-validated past the cap); the runtime parser validates fully.
/ install

Drop it in

One command via the shadcn CLI.

$ pnpm dlx shadcn@latest add https://turtiesocks.github.io/ridiculous/r/transition-editor.json