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.
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 } }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.
@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).
Three usage tiers
From useState-and-go to a compile-time two-level fold that dispatches each declaration into its property's own validator.
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 { โฆ }")
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 }")
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
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.
| Prop | Type | Description |
|---|---|---|
| value | KeyframesString | (string & {}) | Current @keyframes body. Required. An empty / unparseable value seeds a default two-stop body in the editor. |
| onChange | (next: KeyframesString) => void | Fires 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โ plusopacity0โ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 embedsGradientEditorfor them);!important;var()/env()/calc()values; and monotonic-ordering of stops (the UI sorts on format). - The component edits the
@keyframesbody in isolation โ the rule name, animation shorthand, andanimation-*longhands are out of strict scope.
Drop it in
One command via the shadcn CLI.
$ pnpm dlx shadcn@latest add https://ridiculous.turtlesocks.dev/r/keyframes-editor.json