Concepts
The mental model behind remocn-ui primitives — atoms, timeline hooks, theming, and core exports
Two kinds of atom
State atoms
State atoms (Button, Input, Checkbox, Switch) are pure visual functions of (state | style, theme). They read no frame, hold no internal clock, and snap instantly between named states. The component accepts either a state (named preset, snap path) or a style (resolved, interpolated visual, smooth path) — but never reads the frame itself. The state or style value comes from outside: a direct prop, useCurrentState, or a transition hook (see below).
Purity boundary: useCurrentFrame() is never called inside a state atom. Frame resolution happens in the caller — either in useCurrentState, useStateTransition, or a component's transition hook like useButtonTransition. This keeps every state atom safe as a pure renderer.
Motion atoms
Motion atoms (Spinner) loop on useCurrentFrame() with no discrete states. They are pure functions of the frame and safe in Remotion's headless renderer — no Date, no Math.random, no requestAnimationFrame. Spinner is the first motion atom in this tier.
Timeline hooks
useCurrentState — snap resolution
useCurrentState resolves the current state as a latest-step-wins fold: it scans the steps array and returns the state of the last step whose at is at or before the current effective frame.
function useCurrentState<S extends string>(
steps: Step<S>[],
defaultState: S,
speed?: number,
): SdefaultStateis returned before the first step fires.speedscales the playhead:effectiveFrame = currentFrame * speed. A step at{ at: 30 }withspeed: 2activates at render frame 15.atvalues are always Sequence-local authored frames — if the component sits inside<Sequence from={30}>, Remotion shifts the playhead automatically.
useStateTransition — smooth resolution
useStateTransition is the lower-level resolver used by transition hooks. It returns the from state, the to state, and a progress value (0 → 1) over the transition window. Transition hooks call this and blend the visuals; you generally do not call it directly.
function useStateTransition<S extends string>(
steps: Step<S>[],
defaultState: S,
speed?: number,
defaultDuration?: number,
): { from: S; to: S; progress: number }defaultDuration— frames to animate into a step that omitsduration. Defaults to8.progressramps from 0 to 1 over[step.at, step.at + duration), then holds at 1.- Ties on
atresolve later-array-wins (same asuseCurrentState).
Step
interface Step<S extends string = string> {
/** Sequence-local frame at which this state becomes active. */
at: number;
/** The state value to enter at this frame. */
state: S;
/** Frames to animate the transition INTO this state. Omitted → caller's defaultDuration. */
duration?: number;
}useTypewriter — typed text reveal
useTypewriter is the shared typewriter driver. It reads the frame and reveals a string character-by-character at a fixed rate, returning the revealed slice plus status flags. It composes revealCount (count math) and revealedText (the pure slice), so every surface that types text uses one source of truth.
function useTypewriter(
full: string,
options?: { cps?: number; speed?: number; startFrame?: number },
): { text: string; count: number; done: boolean; typing: boolean }cps— characters per second (default20).speedscales the playhead;startFramedelays the start.typingistrueonly while characters are still appearing; pair it with thecaretprimitive so the cursor stays solid mid-type and blinks otherwise:
const tw = useTypewriter(text, { cps: 22, speed, startFrame: 42 });
<span>{tw.text}<Caret blink={!tw.typing} speed={speed} /></span>Components that already receive a controlled count (combobox, command-menu) or reveal by state-transition progress (input) call revealedText(full, count) directly instead of the hook, so they share the same slice without taking on a frame timer.
duration is consumed by useStateTransition (and transition hooks). useCurrentState ignores it — snap resolution has no concept of transition length.
States snap by default
State atoms snap instantly between named states — no automatic cross-fade or enter-tween. Snap is the default and cheapest path: author { at: N, state: "hover" } and the atom jumps straight to that visual at frame N.
Smooth transitions — opt-in via transition hooks
Each state atom ships a transition file (e.g. use-button-transition.ts) that is copied into your project alongside the component. That file:
- owns
DEFAULT_DURATIONand easing - exports a
use<Name>Transition(steps, opts?) → <Name>Stylehook - calls
useStateTransitioninternally and interpolates between state presets
To get smooth motion, call the hook in your composition and feed the result to the component's style prop:
import { Button } from "@/components/remocn/button";
import { useButtonTransition } from "@/components/remocn/use-button-transition";
export const Scene = () => {
const style = useButtonTransition([
{ at: 12, state: "hover" },
{ at: 30, state: "press" },
{ at: 48, state: "loading", duration: 6 },
]);
return <Button label="Continue" style={style} />;
};To tune timing globally, edit DEFAULT_DURATION in your copied use-button-transition.ts. To tune a single transition, add duration to that step — it overrides the file default for that transition only. To tune easing, edit the easings.out(progress) call in the same file.
Theming
The ui tier uses a JS theme object with stock shadcn token names. Values are concrete oklch strings — not CSS custom properties.
import type { RemocnTheme } from "@/lib/remocn-ui";
// All tokens match shadcn's default neutral palette out of the box
const myTheme: Partial<RemocnTheme> = {
primary: "oklch(0.55 0.2 250)", // override just what you need
radius: 6,
};RemocnUIProvider
Wrap your composition (or a subtree) in RemocnUIProvider to set a theme and/or mode for all nested ui primitives:
import { RemocnUIProvider } from "@/lib/remocn-ui";
export const Scene = () => (
<RemocnUIProvider mode="dark" theme={{ primary: "oklch(0.6 0.22 260)" }}>
<Button state={buttonState} label="Continue" />
<UICheckbox label="Remember me" state={checkState} />
</RemocnUIProvider>
);Token resolution order (highest wins): per-component theme prop > RemocnUIProvider theme > defaultLightTheme / defaultDarkTheme per mode. The mode prop on a component wins over RemocnUIProvider's mode.
Available exports from lib/remocn-ui:
| Export | Type | Description |
|---|---|---|
RemocnUIProvider | Component | Context provider for theme and mode |
useRemocnTheme | Hook | Resolves the active theme inside a component |
defaultLightTheme | RemocnTheme | Stock shadcn neutral light palette |
defaultDarkTheme | RemocnTheme | Stock shadcn neutral dark palette |
RemocnTheme | Interface | Full token shape with radius: number |
Animated colors require concrete JS values
CSS custom properties (var(--primary)) work for static styling only. Remotion's headless per-frame renderer cannot resolve var(...) for JS interpolation. If you pass var(--primary) as an animated token, parseColor will warn in dev mode and fall back to the JS default theme value — not your CSS override.
To override an animated color, pass a concrete oklch, hex, or rgb value via the theme prop or RemocnUIProvider. Static colors (borders, backgrounds that never animate) can safely use var() in style props.
A project whose :root overrides --primary will see static elements pick up the CSS variable correctly, but state-dependent visuals (hover background, press darkening) will use the JS theme default. The defaults match stock shadcn, so unless you have custom colors, you will not notice the difference — but if you do have custom colors, supply them through the JS theme.
Core library exports
Installing any component in this tier also installs lib/remocn-ui/ with these modules:
| Module | Key exports |
|---|---|
timeline.ts | useCurrentState, useStateTransition, useTypewriter, framesFor, revealCount, revealedText |
theme.ts | RemocnUIProvider, useRemocnTheme, defaultLightTheme, defaultDarkTheme |
color.ts | mixOklch, parseColor, oklchToRgb, rgbToOklch, toCss |
motion.ts | easings, springs |
types.ts | Step (with optional duration) |
You can import from @/lib/remocn-ui (the barrel) or from the individual module paths.