reelcn
GitHubBack to landing

Standings

A ranked table whose rows swap places like a split-flap departures board between weekly snapshots.

Standings, defaults
Standings, defaults thumbnail, 16:9

Installation

npx shadcn add https://www.reelcn.dev/r/standings.json

Files land in src/reelcn/ with the items it builds on. Dependencies: remotion, zod.

Register the composition

<Composition
  id="Standings"
  component={Standings}
  schema={standingsSchema}
  defaultProps={standingsDefaults}
  calculateMetadata={standingsMetadata}
  width={1920} height={1080} fps={30} durationInFrames={1}
/>

Render

npx remotion render Standings # 16:9
npx remotion render Standings --width=1080 --height=1920 # 9:16

Props

PropTypeDescription
rows*{ name: string; value: number; }[]
theme"daylight" | "midnight" | "mono" | "ledger" | "afterglow" | "chromewave" | "neon" | "paper" | "sunset"Theme preset. Inherits the surrounding `ThemeProvider` when omitted.
brand{ accent?: string; logo?: string; font?: string; }
sfxbooleanPlays a soft whoosh as each scene starts.
musicstringBackground music file, looped under everything at 25% volume.
weeks{ name: string; value: number; }[][]Several weekly snapshots to animate between. Omitted: rows is shown as a single static ranking.

Built from

Use

  • A leaderboard, ranking or standings reveal across several time periods
  • Any "who's on top now" beat with real, specific rows, not a generic bar chart

Avoid

Make it with an agent

With the reelcn agent skill installed, ask for the video in plain words, such as "make a launch video for Relay with reelcn". The skill picks a template, sets its props, reviews a contact sheet and renders.

Guides

Components take their colors from a theme, their timing from motion props and their layout from the canvas.

Source

registry/items/standings.tsx
/**
 * @title Standings
 * @description A ranked table whose rows swap places like a split-flap departures board between weekly snapshots.
 * @category templates
 * @use A leaderboard, ranking or standings reveal across several time periods
 * @use Any "who's on top now" beat with real, specific rows, not a generic bar chart
 * @avoid A single static ranking with no re-sort. Use `bar-race` or `bar-chart` instead
 * @tags standings, leaderboard, rank, split-flap, template
 * @duration data-driven
 * @example
 * <Composition
 *   id="Standings"
 *   component={Standings}
 *   schema={standingsSchema}
 *   defaultProps={standingsDefaults}
 *   calculateMetadata={standingsMetadata}
 *   width={1920} height={1080} fps={30} durationInFrames={1}
 * />
 */
import { AbsoluteFill, type CalculateMetadataFunction, useCurrentFrame, useVideoConfig } from "remotion";
import { z } from "zod";
import { clamp01, rankSlots, tween, useTheme, useViewport } from "./core";
import { type CustomScene, type Scene, type Story, storyFrames } from "./story";
import { defineScene, Storyboard, templateSchema, templateStory } from "./storyboard";
import { TextReveal } from "./text-reveal";

const rowSchema = z.object({ name: z.string(), value: z.number() });

export const standingsSchema = templateSchema.extend({
  rows: z.array(rowSchema),
  /** Several weekly snapshots to animate between. Omitted: `rows` is shown as a single static ranking. */
  weeks: z.array(z.array(rowSchema)).optional(),
});

export type StandingsProps = z.infer<typeof standingsSchema>;

export const standingsDefaults: StandingsProps = {
  rows: [
    { name: "Austin", value: 412 },
    { name: "Denver", value: 398 },
    { name: "Raleigh", value: 355 },
    { name: "Boise", value: 301 },
  ],
  weeks: [
    [
      { name: "Austin", value: 412 },
      { name: "Denver", value: 398 },
      { name: "Raleigh", value: 355 },
      { name: "Boise", value: 301 },
    ],
    [
      { name: "Denver", value: 430 },
      { name: "Austin", value: 415 },
      { name: "Boise", value: 340 },
      { name: "Raleigh", value: 320 },
    ],
  ],
};

type Row = { name: string; value: number };

function StandingsScene({ rows, weeks }: { rows: Row[]; weeks?: Row[][] }) {
  const theme = useTheme();
  const { u } = useViewport();
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();
  const snapshots = weeks && weeks.length > 0 ? weeks : [rows];
  const HOLD = Math.round(fps * 1.5);
  const TRANSITION = Math.round(fps * 0.8);
  const period = HOLD + TRANSITION;
  const index = Math.min(snapshots.length - 1, Math.floor(frame / period));
  const nextIndex = Math.min(snapshots.length - 1, index + 1);
  const t = clamp01(tween(frame - index * period, fps, { from: HOLD, duration: TRANSITION, motion: "smooth" }));
  const current = snapshots[index];
  const fromSlots = rankSlots(current);
  const toSlots = rankSlots(snapshots[nextIndex]);
  const rowH = u(64);

  return (
    <AbsoluteFill style={{ alignItems: "center", justifyContent: "center" }}>
      <div style={{ position: "relative", width: u(640), height: rowH * current.length }}>
        {current.map((row, i) => {
          const fromSlot = fromSlots[i];
          const toSlot = i < snapshots[nextIndex].length ? toSlots[i] : fromSlot;
          const y = (fromSlot + (toSlot - fromSlot) * t) * rowH;
          const delta = toSlot - fromSlot;
          const arrowColor = delta < 0 ? theme.colors.success : delta > 0 ? theme.colors.danger : theme.colors.muted;
          return (
            <div
              key={row.name}
              style={{
                position: "absolute",
                top: y,
                left: 0,
                right: 0,
                height: rowH,
                display: "flex",
                alignItems: "center",
                gap: u(18),
              }}
            >
              <div style={{ width: u(44) }}>
                <TextReveal
                  key={`${row.name}-${index}`}
                  text={String(fromSlot + 1)}
                  effect="split-flap"
                  split="char"
                  size={30}
                  font="mono"
                  // The rank digit must stay put through the scene's own final frame, `useMotion`'s
                  // default auto-fade near the end of the enclosing Sequence (this scene spans every
                  // week, not just the one this row belongs to) would otherwise fade the last-shown
                  // rank to nothing right before the video ends.
                  exit={false}
                />
              </div>
              <span style={{ color: theme.colors.foreground, fontFamily: theme.fonts.body, fontSize: u(26), flex: 1 }}>
                {row.name}
              </span>
              <span style={{ color: theme.colors.muted, fontFamily: theme.fonts.mono, fontSize: u(20) }}>
                {row.value}
              </span>
              <span style={{ color: arrowColor, fontSize: u(20), width: u(20) }}>
                {delta < 0 ? "▲" : delta > 0 ? "▼" : "–"}
              </span>
            </div>
          );
        })}
      </div>
    </AbsoluteFill>
  );
}

const standingsScene = defineScene({
  type: "standings-beat",
  schema: z.object({ rows: z.array(rowSchema), weeks: z.array(z.array(rowSchema)).optional() }),
  component: StandingsScene,
  duration: (scene: { weeks?: Row[][] }) => Math.max(1, scene.weeks?.length ?? 1) * 2.3,
});

export function standingsStory(props: StandingsProps): Story {
  const scenes: (Scene | CustomScene)[] = [
    { type: "standings-beat", rows: props.rows, weeks: props.weeks } as CustomScene,
  ];
  return templateStory(props, scenes as Scene[]);
}

const standingsScenes = [standingsScene];

export function Standings(props: StandingsProps) {
  return <Storyboard story={standingsStory(props)} scenes={standingsScenes} />;
}

// Not `templateMetadata(standingsStory)`: that helper calls `storyFrames(story)` with no custom scene
// rules, and this story's one scene is the template-local `defineScene` type above ("standings-beat")
// without its rule, `sceneSeconds` falls through to `Scene`'s own switch, matches no case, and returns
// `undefined`, making `durationInFrames` NaN (same failure mode documented in product-launch.tsx).
export const standingsMetadata: CalculateMetadataFunction<StandingsProps> = ({ props }) => ({
  durationInFrames: storyFrames(standingsStory(props), standingsScenes),
});

On this page