Creating Popkorn Animations
SkillDev toolsUse when authoring or editing a Popkorn scene (.css DSL for @popkorn/player) — writing shapes, keyframe animations, symbols, motion paths, masks, or interactive scenes in this repo's CSS-subset animation language.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Creating Popkorn Animations skill
What this skill tells your AI
The instructions your AI receives, as published by ayarse/popkorn in .claude/skills/creating-popkorn-animations/SKILL.md and read by ahel’s review.
Overview
Popkorn is a CSS-subset DSL that compiles to a 2D scene graph and plays on Canvas.
It looks like CSS but is NOT CSS — the parser accepts any property: value, and meaning
is assigned at build time. Unknown properties parse fine and are silently ignored. So
the danger isn't syntax errors; it's authoring valid-looking declarations that do nothing.
Pipeline: source → parse() → StyleSheet AST → buildSceneGraph() → RenderLoop → Canvas2D.
Full spec: reference.md. Read it before using any feature not shown below.
No box model, ever — permanent, not a gap. No position/margin/
padding/flex/grid, and no ::before/::after. Workarounds:
left/top→x/y(orcx/cy) — literal coordinates, not flow.margin/padding→ arithmetic on the child's own coordinates (e.g. a "16px padding" is justx: 16px; y: 16pxon the inner shape).- Centering → compute it:
(parentWidth - childWidth) / 2, noauto. - Rows/columns → fixed-stride positions per child (
x: i * stridePx), or give agroupatransform: translate(...)per row/column and keep each child's geometry local to(0,0). - Stacking → document order (later sibling paints on top), override with
z-index: <int>(negatives allowed). - Pseudo-elements (
::before/::after) → a named> #childshape instead of a pseudo-selector. - Repeated decoration (the CSS
box-shadow-stamping trick) →@definethe shape once, then multipleuse:instances, each overriding only what differs (position, fill, etc.) — real, independently animatable copies.
Workflow
- Start with
:root { width; height; background }(only hex colors register here; custom--propslive here too). - Give every node an
#idand atype:declaration. Notype:→ it's agroup. - Set geometry (props are type-gated:
ronly on circle,cx/cyon circle/ellipse/star/polygon…). - Set paint:
fillandstrokeboth default tonone— a shape with onlystroke-widthshows nothing. - Animate via
@keyframes name {…}+ theanimation:shorthand (or theanimation-*longhands, which compose per CSS: later declarations win per sub-property). - Verify by parsing (see below) — the parser won't catch dead properties, so cross-check names against reference.md.
Quick reference
| Need | Syntax |
|---|---|
| Stage | :root { width: 800px; height: 600px; background: #0f0f23; } |
| Shapes | type: rect(x,y,width,height,rx,ry) · circle(cx,cy,r) · ellipse(cx,cy,rx,ry) · path(d) · star/polygon(sides,outer-radius,inner-radius) · text · image · group |
| Paint | fill/stroke (hex, rgb(), linear-gradient(), named color, none); stroke-width, stroke-linecap, stroke-linejoin, stroke-dasharray, fill-rule, opacity |
border-radius | 1 value → uniform rx/ry; 2–4 values → CSS corner shorthand, expands to animatable border-top-left-radius etc. (rect only; no elliptical / form) |
box-shadow | [inset] dx dy [blur] [spread] [color], comma-separated multi-shadow, animatable; spread only inflates rect/circle/ellipse (paths ignore it) |
mix-blend-mode | all 16 CSS keywords, per-shape (no group isolation), static |
| Transform | transform: translate(x,y) rotate(45deg) scale(1.2) · transform-origin: center (no skew) |
| Individual transforms | translate: 40px 10px · rotate: 45deg · scale: 1.2 (same channels as transform:, last-wins) |
| Animate | animation: <name> <dur> <easing> <count> <dir> <delay> e.g. pulse 1.5s ease-in-out infinite |
| Keyframes | @keyframes n { 0% {…} 50% {…} 100% {…} } (transform: decomposes & merges) |
| Per-kf easing / hold | animation-timing-function: ease-out (or step-end, steps(3, jump-end)) inside a keyframe block — eases the segment from that keyframe to the next |
| Composite | animation-composition: add (longhand only, not in shorthand) — adds numeric channels onto the base pose; color/path fall back to replace |
| Easings | linear ease ease-in ease-out ease-in-out step-start step-end cubic-bezier(…) steps(<n>, <pos>) linear(<stops>) |
| Spring/bounce | linear(0, 1 33%, 0.55 46%, 1 62%, 0.78 74%, 1) — overshoot control points fake physics with 2 keyframes |
| Symbols | @define name {…} then #x { use: name; cx: …; fill: … } (use-site overrides) |
| Repeat / instancing | repeat: <int> on any node stamps N sibling copies with derived ids #field → field-1…field-N (descendants re-suffix too); composes with use:, nests multiplicatively. Static count only (no input()); repeat: 1 ≡ absent. Differentiate copies with sibling-index() / sibling-count() (1-based position / total among all siblings, so give a repeated family its own group) or random(per-element) — e.g. cx: calc(sibling-index() * 40px), animation-delay: calc(sibling-index() * -0.1s). See reference.md §Repeat & sibling math |
| Nesting | > #child { … } inside a rule body |
| Interactivity | :root { --cx: input(cursor.x) } + cx: var(--cx); &:hover {…} &:active {…}; cursor: pointer (pointer cursor on hover); clicks emit a popkorn:click DOM event (detail.{id,path,x,y}, no opt-in) |
Typed var() | --brand: #e94560 / --label: "Score" / --n: 30px then fill: var(--brand) / content: var(--label) / r: var(--n) — numeric interpolates, color/string snap (discrete); input() stays numeric-only |
| Random constant | random([per-element || --ident]?, <min>, <max>[, by <step>]) — a fixed roll frozen at build (not live noise), any number/calc() operand; carries min/max unit. Default = one shared roll; per-element rolls per instance (particles); --ident correlates calls; deterministic from source. See reference.md §3 |
| Text | text-align: center (maps to text-anchor) · letter-spacing: 2px (animatable; no-op on RN/Skia) · line-height: 1.4 (animatable) · content: "a\nb" for multi-line (\n \r \t \" \\ unescape) |
| Transitions | transition: fill 0.3s ease, transform 0.2s — state flips tween (enter+exit) instead of snapping; runtime-only, timeline stays pure |
| State machines | @machine m { initial: off; state off { to: on on click(#btn) } state on { to: off on click(#btn) } } + #btn:state(on) { animation: … } — named states that outlive the pointer (toggles, sequences, timeouts); :state() can start animation: (the jump over :hover). See reference.md §14, examples 11/12 |
| Scrubbing | animation-timeline: var(--progress) or input(scroll.progress) — drive an animation by a 0..1 value instead of the clock |
| Filters | filter: blur(12px) (radius animatable in @keyframes) · filter: blur(2px) drop-shadow(4px 6px 8px rgba(0,0,0,.4)) (drop-shadow static); applies to node + subtree |
| Motion path | offset-path: path('…'); offset-distance: 50%; offset-rotate: auto (animate offset-distance) |
| Mask | clip-path: circle(80 at 200 200) · mask: #layer alpha |
| Gradient/path animation | animate fill: linear-gradient(…) (same type + stop count) or d: 'M…' (same command sequence) in @keyframes; incompatible endpoints step |
| Retime subtree | time-offset: 2s; time-scale: 0.5 on a group — shifts + scales that node and all descendants (precomp-style; static) |
| Group opacity | cascades: opacity on a group dims its whole subtree |
| Paint order | siblings paint in document order; override with z-index: <int> (negatives allowed; also sets hit-test priority) |
| Visibility window | visible-from: 1s; visible-until: 3s — show node + subtree only in that scene-local window |
| Embed options | <popkorn-player loop controls autoplay fit="contain"> (fit: contain/cover/fill/none) |
Common mistakes
- Shape invisible →
filldefaults tonone. Set a fill (or stroke color, not just width). type:forgotten → node becomes agroup(nothing draws). Always declaretype:.- Property does nothing → it's likely unsupported (
skew,object-fit,href,points). Parses silently, no effect. Check reference.md §17. (mix-blend-mode,text-align,line-height,letter-spacing,border-radius,box-shadowall do work now.) - Wrong geometry prop for the type → silently ignored (
ron a rect,xon a circle). .5or//comments → invalid. Write0.5; use/* */only.- A color bound via
var()doesn't tween → it snaps instead of interpolating (the color-binding path re-resolves rather than lerping); numericvar()/input()still interpolate normally. (Solid colors, gradient stops, and pathddo animate in@keyframes— gradients/paths only between compatible endpoints; see reference.md §12.) letter-spacinglooks fine on web but does nothing on RN/Skia — pinned backend divergence, not a bug.- fill-mode surprise → Popkorn defaults to
forwards(holds final frame), unlike CSS'snone.
Verify a scene parses
bun --filter @popkorn/parser test # AST contract tests
# Or parse ad-hoc: import { parse } from '@popkorn/parser'; parse(source)
Live-preview a scene by loading it into a <popkorn-player> element (see reference.md §15) via bun run dev.
Signals
- GitHub stars
- 23
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
creating-popkorn-animations- Source
- github.com/ayarse/popkorn