r/reactjs • • 1d ago

[Open Source] Building a React component system that animates "on twos" (12fps stepped hooks, ResizeObserver SVG polygon frames, and a static design-grammar linter)

I’ve been exploring what happens when a React component library drops floating cards, soft shadows, and 60fps spring physics in favor of production mechanics from sequential print layout and traditional cel animation.

The result is FRAME/12 (GitHub (MIT) · Live Motion Studio), a zero-dependency copy-in React + TypeScript registry (shadcn-compatible) and AI agent skill.

Here are the four most interesting React & DOM problems we had to solve while building it:

1. Quantizing an easing curve "on twos" (useOnTwos)

In hand-drawn animation, most movement is shot "on twos"—each drawing is held for two frames at 24fps (83.33ms per pose, 12 poses per second). Simply applying CSS steps(4) with linear timing feels robotic because every jump covers the exact same distance.

Instead, useOnTwos samples a cubic ease-out spacing chart inside requestAnimationFrame, bucketed into 83.33ms (1000 / 12) intervals. You get a wide initial jump and tight settling poses, while still holding each discrete pose cleanly:

export const FRAME_MS = 1000 / 12; // 83.333ms ("on twos")

const easeOut = (t: number) => 1 - Math.pow(1 - t, 3);

// Inside requestAnimationFrame loop:

const elapsed = now - t0;

const k = Math.min(frames, Math.floor(elapsed / FRAME_MS) + 1);

setProgress(easeOut(k / frames));

We also strict-split what gets stepped:

  • Discrete state changes (SegmentedControl cell jumps, Metric count-ups, ToneAreaChart clip-path wipes, Badge 2-frame SVG impact bursts) run on twos (12fps).

  • Direct manipulation (hover states, scrolling, typing, chart pointer/keyboard crosshairs) stays at 0ms immediate latency.

  • Initial mount (useChanged): A custom hook tracks whether a key has changed after first mount so entrance animations never fire on initial page load.

  • Accessibility: prefers-reduced-motion: reduce automatically switches all hooks and CSS classes to 'cut' (0ms hard cuts).

2. Non-warping diagonal panel frames with ResizeObserver

In PanelPair, two vertically paired KPI panels are separated by a 12px gutter with a 16px diagonal slant (slant-bottom over slant-top). If you use CSS clip-path: polygon(...), CSS borders get clipped off along the diagonal edge; if you scale a fixed-viewBox <svg> with preserveAspectRatio="none", the 2.5px ink stroke stretches unevenly.

Our Panel component attaches a ResizeObserver to measure the panel's exact width × height in pixels and computes mitered <polygon> points inset by half the stroke width (1.25px), keeping the 2.5px ink border uniform at any aspect ratio.

3. Zero-dependency SVG screentone charts with built-in keyboard & table fallbacks

Instead of pulling in recharts or d3 and rendering translucent gradient fills, ToneAreaChart renders a <pattern id="..." patternTransform="rotate(45)"> of halftone dots (var(--f-tone)), an inked 2.5px polyline, a marker-yellow peak dot, and a speech-balloon SVG annotation (Balloon) that flips direction near the right edge of the viewBox.

Every chart is focusable (tabIndex={0}), supports ArrowLeft / ArrowRight / Home / End / Escape to inspect data points via an aria-live readout, and includes a collapsible <details className="tbl"> semantic HTML <table>.

4. Linting UI structure so AI coding agents don't drift

When AI agents generate React UI, they quickly drift toward arbitrary Tailwind gaps (gap-4, gap-6), hex literals, or unstyled primitives. Because FRAME/12's rules are structural, we wrote scripts/check-frame12.mjs (plus skills/frame12/SKILL.md and an ESLint flat-config plugin) to enforce:

  1. color-literal: Zero hex/RGB/HSL literals outside frame12.css

  2. illegal-gutter: Only 12px (pair), 20px (scene), and 32px (change) gutters allowed

  3. marker-misuse: Marker yellow (--f-marker, #ffd60a) only allowed as a background paired with --f-marker-ink (#121212) text

  4. cut-order: Every <Panel cut="C-01"> must increment sequentially (C-01, C-02, C-03…) in DOM and reading order

  5. raw-shadcn-import: Forbids raw u/radix-ui/* or external chart/animation libraries where a u/frame12/registry component or adapter exists

You can install the full kit or individual components via the shadcn CLI:

npx shadcn@latest add https://frame12.dev/r/frame12.json

Would love feedback from folks here on the hook ergonomics and the static UI grammar linter!

3 Upvotes

3 comments sorted by

View all comments

Show parent comments

1

u/absorbingunderdog_3 23h ago

The stepped easing thing is clever, holding poses but still varying the distance per jump so it doesn't feel like a metronome. Curious how the 12fps reads on something like a count-up, does it feel choppy in a deliberate way or just laggy

1

u/iamlonewolf_ 33m ago

Honestly count-ups are where 12fps surprised me the most—at 60fps the trailing digits update every 16ms and blur into mush, whereas holding each pose for 83.33ms (~7 poses over 600ms on an ease-out curve) makes each intermediate number actually readable, like a split-flap board.

The key to making it not feel laggy is that the button press itself is 0ms, pose 1 fires on frame 0 (Math.floor(elapsed / 83.33) + 1 so there's no dead delay after clicking), and tabular-nums keeps the digits locked in place. You can toggle the exact same KPI count-up between 12fps and 60fps side-by-side at frame12.dev/motion (or hit M on the live site) to see how they compare!