/ component

Keyframes Editor

Ridiculously typed editor for a CSS @keyframes body โ€” the composition flagship. A two-level type validates each stop selector and dispatches every declaration into its property's own validator (transform, filter, color, easing, โ€ฆ). A timeline embeds the real editors per stop, with a scrubbed preview.

/ basic-usage

Edit a @keyframes body

Controlled value + onChange. The component edits the body of a @keyframes rule โ€” the ordered list of stops (from, to, and N%) โ€” not the rule name. The popover trigger shows the stop count and a truncated preview; the panel exposes a timeline, per-stop declarations, and a scrubbed preview. Wrap the emitted body in your own @keyframes spin { โ€ฆ }.

@keyframes spin { from { opacity: 0 } to { opacity: 1 } }
/ animation-timeline

The timeline, scrubbed live

The hero: a 0โ€“100% timeline of keyframe stops (from = 0%, to = 100%, N% between). Select a stop to reveal its declarations โ€” and each declaration opens the real typed editor for its property: transform embeds the TransformBuilder, filter the FilterBuilder, color properties the ColorPicker, background the GradientEditor, *-timing-function the EasingPicker, and length / opacity a UnitInput. Drag the play head (or hit โ–ถ play) and the preview box interpolates between the surrounding stops. This composition โ€” six typed editors, one per declaration โ€” is the affordance.

timeline
0%
50%
100%
stop 0% declarations
from { transform: translateX(0px); opacity: 0 } 50% { transform: scale(1.2); opacity: 1 } to { transform: translateX(120px); opacity: 1 }
preview ยท 0%

Lightweight JS interpolation between adjacent stops (numbers / lengths / opacity). Non-numeric values snap to the nearest stop โ€” this is a scrub demo, not a full CSS animation engine.

produced value
@keyframes slide { from { transform: translateX(0px); opacity: 0 } 50% { transform: scale(1.2); opacity: 1 } to { transform: translateX(120px); opacity: 1 } }

The editor emits the body; wrap it in your own @keyframes name. The scrub preview is a lightweight JS interpolation between adjacent stops (numbers / lengths / opacity) โ€” non-numeric values snap to the nearest stop. It is a scrub demo, not a full CSS animation engine (spec A6).

/ types

Three usage tiers

From useState-and-go to a compile-time two-level fold that dispatches each declaration into its property's own validator.

01 casual
string

Pass any string

useState<string>. No compile-time validation; the runtime parser splits the block list into stops and declarations, and propertyEditorKind routes each declaration to its embedded editor โ€” even values the strict tier defers.

from { transform: translateX(0px) } to { transform: translateX(100px) }
const [value, setValue] = useState<string>("from { transform: translateX(0px) } to { โ€ฆ }")
02 intellisense
KeyframesString

Keyframes-body-shaped hints

State typed as KeyframesString โ€” a keyframes-body-shaped string (and the onChange return type). The permissive suggestion type that pairs with the strict-tier gate; StopsOf<S> counts the blocks at the type level.

0% { color: #f00 } 100% { color: #00f }
const [value, setValue] = useState<KeyframesString>("0% { color: #f00 } 100% { color: #00f }")
03 strict
KeyframesLiteral<S>

Two-level grammar typed at compile time

cssKeyframes() validates the block structure and selector range (from / to / 0โ€“100%), then dispatches each declaration's value on its property name into that property's own validator โ€” transform โ†’ TransformLiteral, filter โ†’ FilterLiteral, color properties โ†’ ColorLiteral, *-timing-function โ†’ EasingLiteral, opacity โ†’ 0โ€“1, length properties โ†’ <length-percentage> โ€” resolving any violation to never before you run the code.

from { opacity: 0 } to { opacity: 1 }
cssKeyframes("0% { color: #f00 } 100% { color: #00f }") // โœ“
// @ts-expect-error selector out of 0โ€“100
cssKeyframes("150% { opacity: 1 }")
// @ts-expect-error opacity not in 0โ€“1
cssKeyframes("from { opacity: 2 }")
// @ts-expect-error value isn't a transform list
cssKeyframes("from { transform: 5 }")

Note: unknown properties' values, background/background-image (the gradient editor emits suggestion strings, not a strict literal), !important, and calc()/var() values are deferred to the lenient runtime parser. Colors use color-picker forms (#f00 / oklch(โ€ฆ)), not named colors.

/ api

API

Public surface โ€” component props, runtime helpers, and the type exports.

ยง KeyframesEditor / KeyframesEditorPanel

<KeyframesEditor
  value: KeyframesString | (string & {})
  onChange: (next: KeyframesString) => void
  className?: string
  aria-label?: string
/>

KeyframesEditor is popover-wrapped (a trigger showing the stop count + truncated body); KeyframesEditorPanel renders the same editor inline. Both are controlled. The component edits the body of a @keyframes rule โ€” the block list โ€” not the rule name; the consumer owns the @keyframes name { โ€ฆ } wrapper.

PropTypeDescription
valueKeyframesString | (string & {})Current @keyframes body. Required. An empty / unparseable value seeds a default two-stop body in the editor.
onChange(next: KeyframesString) => voidFires when the body changes. Emits the canonical re-serialized body (stops sorted from โ†’ % โ†’ to).

ยง Sub-components

<KeyframeTimeline blocks selected position onSelect onPosition onAddStop onRemoveStop />

The 0โ€“100% track. One labelled stop marker per block (from=0%, to=100%, N% between) with add / remove, and a labelled play-head <input type=range> that scrubs the preview. Selecting a marker raises onSelect; the container reveals that stop's declarations.

<DeclarationRow index declaration onChange onRemove />

One property: value row. A property select picks the property; the value editor is chosen by propertyEditorKind โ€” TransformBuilder, FilterBuilder, ColorPicker, GradientEditor, EasingPicker, UnitInput, or a plain input. Add / remove declarations.

<KeyframePreview blocks position onPosition? />

The live scrubbed preview box. A LIGHTWEIGHT JS interpolation between adjacent stops (numbers / lengths / opacity); non-numeric values snap to the nearest stop. A play/pause toggle auto-advances when onPosition is wired. NOT a full CSS animation engine (spec A6).

<MiniSelect value options onChange />

The local compact <select> chrome. Each component owns its copy (registry self-containment) โ€” it is not imported from query-builder.

<LiveString value />

The produced @keyframes body rendered in a <code> block.

ยง Runtime helpers

cssKeyframes<S>(value: S & KeyframesLiteral<S>): S

Call-site validator for a @keyframes body. Mirrors cssTransform() / cssPositionArea().

parseKeyframes(src): { blocks: KeyframeBlock[]; error: string | null }

String โ†’ editor state. The runtime superset of the strict tier: splits the block list (sel { decls }), selectors on commas, declarations on `;` then the first `:`; surfaces a parse error message.

formatKeyframes(blocks): string

Canonical re-serialization. Stops are sorted by their first selector's percent (from=0 < N% < to=100).

propertyEditorKind(property): KeyframePropertyKind

Which embedded editor a declaration opens โ€” the runtime mirror of the type DispatchValue table. Returns "transform" | "filter" | "color" | "easing" | "opacity" | "length" | "plain".

selectorToPercent(sel)  ยท  percentToSelector(n)

The timeline coordinate map: from โ‡„ 0, to โ‡„ 100, an N% selector โ‡„ its numeric part.

defaultKeyframes()

A valid, parseable two-stop seed (from { opacity: 0 } to { opacity: 1 }) for a freshly-created editor.

ยง Types

KeyframesLiteral<S>
Strict validator โ€” S if S is a valid @keyframes body, else never. The two-level fold (block selectors + per-property value dispatch) is the namesake.
KeyframesString
Suggestion type (body-shaped string). The onChange return type.
StopsOf<S>
The block (stop) count of a body, at the type level.
KeyframePropertyKind
The embedded-editor / validator dispatch tag: "transform" | "filter" | "color" | "easing" | "opacity" | "length" | "plain".
KeyframeBlock / Declaration / KeyframesValue
The internal editor state โ€” a stop ({ selectors; declarations }), a single { property; value } declaration, and the whole-body container. Exported for advanced use.

ยง Strict-tier scope (validated vs deferred)

  • Validated: block structure (sel { decls }); selector range (from / to / 0โ€“100%); and the KNOWN-property values via dispatch into the four sibling validators โ€” transform โ†’ TransformLiteral, filter/backdrop-filter โ†’ FilterLiteral, the color properties โ†’ ColorLiteral, *-timing-function โ†’ EasingLiteral โ€” plus opacity 0โ€“1 and length properties <length-percentage>.
  • Deferred (lenient โ†’ runtime parser): unknown properties' values; background/background-image (the gradient editor exports suggestion strings, not a strict literal โ€” the UI still embeds GradientEditor for them); !important; var()/env()/calc() values; and monotonic-ordering of stops (the UI sorts on format).
  • The component edits the @keyframes body in isolation โ€” the rule name, animation shorthand, and animation-* longhands are out of strict scope.
/ install

Drop it in

One command via the shadcn CLI.

$ pnpm dlx shadcn@latest add https://ridiculous.turtlesocks.dev/r/keyframes-editor.json