Status Cycle
A status pill whose label rolls behind a hard clip while its width springs past the target and back, then the field crossfades to a column of chips stepping up from below
Installation
$ pnpm dlx shadcn@latest add @snapcn/status-cycleComponent code
The exact file shadcn add copies into your project.
Usage
A prefix, a list of statuses and a list of chips. Everything else has a measured default.
// src/Root.tsx
import { Composition } from "remotion";
import { StatusCycle } from "@/components/snap-cn/status-cycle";
const Cycle = () => (
<StatusCycle
prefix="snapcn is"
statuses={["animating", "transitioning", "rendering a scene", "installed"]}
chips={["text-reveal", "phone-frame", "answer-stream", "word-flip"]}
/>
);
export const RemotionRoot = () => (
<Composition
id="StatusCycle"
component={Cycle}
durationInFrames={150}
fps={30}
width={1280}
height={720}
/>
);durationInFrames has to cover the whole arc: the intro, one statusHold per
status, the crossfade, and one chipStagger per chip. The default 150 fits the
default content with a hold at the end; add roughly 18 frames per extra status
and 8 per extra chip.
How the swap works
Three things happen on the same frame, and keeping them on the same frame is most of the effect.
The label rolls. The outgoing label translates up and out through the pill's top edge while the incoming one arrives from below, both hard-clipped by the pill's rounded box. There is no crossfade — the label's ink never dims — and the travel is one pill height.
The pill's width springs past its target. Not a spring, though: the curve is
cubic-bezier(0.65, 0.2, 0.3, 1.5), and the y2 > 1 control value is the
overshoot. A real spring cannot make this shape. One frame into an eleven-frame
rise a spring released from rest is already 4.1% of the way there; the reference
is at 0.65%, six times flatter, and no damping/stiffness pair fixes that without
losing the early peak.
The line re-centres. The lockup is centred as a whole, so a widening pill pushes the prefix left and a narrowing one lets it back. The prefix does not re-animate; it only moves.
The label is laid out at its full final width and centred, and the widening pill reveals it from the middle outward — so during a morph you see the middle of the new label with both ends clipped. That is why the width is its own animated quantity and is never derived from a padded text box.
Content of any length
Every width in the scene is measured at runtime behind delayRender() — the
prefix's glyph advances, each status label, each chip — so a longer status or a
longer chip simply makes a wider box. Nothing is tuned to one sentence. Every
other proportion is a fraction of the composition rather than a pixel, so the
scene holds its shape at 720p, 1080p or a vertical crop.
Colour
Leave the colour props off and the scene paints from the design system:
primary for the first field, background for the second, foreground for the
pill and the ink, and a pale tint of the field for the pill's label. Pass any of
them to override.
<StatusCycle
fieldColor="#0f766e"
pageColor="#f5f5f4"
chipFills={["#ffffff", "#e7f5f3", "#0f766e"]}
/>Overriding the motion
Every curve and duration is a measured default on the exported
STATUS_CYCLE_MOTION, and motion is merged over it — so you override one
without restating the rest.
import { Easing } from "remotion";
<StatusCycle
motion={{
// Flatten the overshoot out of the width morph.
widthEase: Easing.bezier(0.4, 0, 0.2, 1),
// And give the chips a slower step.
scrollTau: 0.12,
}}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
prefix | string | "snapcn is" | The line that does not change, to the left of the pill |
statuses | string[] | string | ["animating", "transitioning", "rendering a scene", "installed"] | Pill labels, in order. Also accepts a comma-separated string, which is what the customizer passes |
chips | string[] | string | ["text-reveal", "phone-frame", "answer-stream", "word-flip", "orbit-gallery"] | Act-2 chip labels, in order. Also accepts a comma-separated string |
fieldColor | string | theme.primary | The field act 1 sits on |
pageColor | string | theme.background | The field act 2 sits on; the crossfade runs between the two |
pillColor | string | theme.foreground | Pill fill |
pillLabelColor | string | a pale tint of fieldColor | Pill label ink |
prefixColor | string | theme.foreground | Prefix ink |
chipFills | string[] | string | card, a pale tint of the field, the field | Chip fills, cycled in order. Three of them gives the reference's period-3 rhythm |
chipTextColor | string | theme.foreground | Chip ink. One colour on every chip, including the saturated one |
chipBorderColor | string | mixOklch(theme.border, theme.foreground, 0.28) | Chip hairline. The default is the design system's own recipe for carrying a border token up to a surface this size |
fontWeight | number | 400 | Act-1 type weight |
chipFontWeight | number | 400 | Act-2 type weight. Measured 400 — the chips are bigger, not heavier, and bumping this is the usual way to make them look cheap |
pillRadius | number | 0.229 x pill height | Pill corner radius in px. The default ratio is very nearly shadcn's own radius token over a 40px control (10/40 = 0.25), so it already sits in the design system. Not a stadium — h/2 fits the reference's edge two orders of magnitude worse |
chipRadius | number | 0.162 x chip height | Chip corner radius in px |
chipBorderWidth | number | 0.017 x chip height | Chip hairline width in px |
pillCenterY | number | 0.516 | Pill centre as a fraction of frame height. The reference's lockup rides 1.6% low |
motion | Partial<StatusCycleMotion> | STATUS_CYCLE_MOTION | Curve and timing overrides: widthEase, widthSeconds, rollEase, rollSeconds, rollTravel, fadeSeconds, exitSeconds, scrollTau, letterSpring, letterStagger. Every default is fitted from frame data, so overriding one is a departure rather than a tweak |
fontSize | number | 8.7% of the composition height | Act-1 type size in px |
chipFontSize | number | 22.2% of the composition height | Act-2 type size in px |
fontFamily | string | Inter | Loaded through @remotion/google-fonts, so the Player and the render agree |
introFrames | number | 24 | Frames the per-letter intro cascade gets before the first status swap |
statusHold | number | 18 | Frames from one status swap to the next. Shorter than the 18-frame width morph on purpose — the next swap starts while the last is still settling |
chipStagger | number | 8 | Frames from one chip's arrival to the next |
startAt | number | 0 | Frame the scene starts on |
speed | number | 1 | Playback rate. Below 1 the last chip never arrives |
theme | Partial<SnapCnTheme> | — | Design-system token overrides |
mode | "light" | "dark" | — | Which side of the token set to resolve |