Add TikTok video skills (caption-animation, countdown-video, lower-thirds, short-form-video)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011XhxckAYjGMLrLECWuKBzg
This commit is contained in:
18 files changed
+1842
No files matched your search
@@ -0,0 +1,25 @@
|
||||
# Countdown Video
|
||||
|
||||
Make a countdown that ends exactly when it should. The whole craft is one idea: derive the remaining time from the frame (or a monotonic timestamp), then format and animate that number — never count down with a `setInterval`. Covers livestream "starting soon" screens, launch and flash-sale countdowns, and looping backgrounds. For creators who need a timer that lands on zero on the exact frame.
|
||||
|
||||
## When it activates
|
||||
|
||||
- "Make a countdown timer video" or "create a countdown animation."
|
||||
- "Build a stream starting soon countdown" or "do an event/launch/sale countdown."
|
||||
- "Animate a mm:ss or dd:hh:mm:ss timer."
|
||||
- "Add digit roll/flip transitions to a counter" or "loop a countdown background."
|
||||
|
||||
## Example prompts
|
||||
|
||||
- "Build a 10-minute 'be right back' livestream countdown."
|
||||
- "Make a dd:hh:mm:ss launch countdown to a fixed target date."
|
||||
- "Add flip transitions to an mm:ss timer that hits zero on the exact frame."
|
||||
|
||||
## What's inside
|
||||
|
||||
- `SKILL.md` — frame-accurate (drift-free) timers, time formatting, and a configurable duration prop.
|
||||
- `references/countdown-cookbook.md` — recipes for common countdown scenarios.
|
||||
- `references/digit-flip.md` — digit flip/roll transition techniques.
|
||||
|
||||
---
|
||||
Part of **[Short-Form Video Skills](../../)** · Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=tiktok-video-skills&utm_content=skill_footer&utm_term=countdown-video)** — the AI motion agent for editable, on-brand motion graphics.
|
||||
@@ -0,0 +1,152 @@
|
||||
---
|
||||
name: countdown-video
|
||||
description: This skill should be used when the user asks to "make a countdown timer video", "create a countdown animation", "build a stream starting soon countdown", "do an event/launch/sale countdown", "animate a mm:ss or dd:hh:mm:ss timer", "add digit roll/flip transitions to a counter", or "loop a countdown background". Covers frame-accurate (drift-free) timers, digit flip/roll motion, time formatting, and a configurable duration prop.
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# Countdown Video
|
||||
|
||||
Make a countdown that ends exactly when it should. The whole craft is one idea: derive the remaining time from the frame (or a monotonic timestamp), then format and animate that number — never count down with a `setInterval`. Covers livestream "starting soon" screens, launch/sale countdowns, and looping backgrounds.
|
||||
|
||||
## When to use
|
||||
|
||||
- "Starting soon" / "be right back" livestream screens with a 5–10 min timer.
|
||||
- Launch and flash-sale countdowns (dd:hh:mm:ss down to a target date; mm:ss for urgency).
|
||||
- Any animated counter where the number must land on zero on the exact frame.
|
||||
|
||||
## The one rule: drive the number from the clock, never a tick
|
||||
|
||||
A `setInterval(fn, 1000)` countdown **drifts** — the callback fires *at least* 1000 ms later, never exactly, and stacks up when the tab is throttled or a render frame is slow. Reports of ~1 second lost per minute are common. The fix is the same for rendered video and live web: compute remaining time as a pure function of an authoritative clock, so a late frame self-corrects on the next frame instead of accumulating error.
|
||||
|
||||
| Context | Authoritative clock | Remaining time |
|
||||
|---|---|---|
|
||||
| Remotion render | `useCurrentFrame()` | `total − frame/fps` |
|
||||
| Web (live page) | `performance.now()` or `Date.now()` | `targetMs − now` |
|
||||
| To a fixed date | `Date.now()` | `targetTimestamp − Date.now()` |
|
||||
|
||||
Never store remaining time in state and decrement it. Recompute it every frame.
|
||||
|
||||
## Frame-accurate countdown (Remotion → MP4)
|
||||
|
||||
`useCurrentFrame()` is deterministic: frame N is always rendered at exactly `N/fps` seconds. Subtract from the total and the timer is drift-free by construction.
|
||||
|
||||
```tsx
|
||||
import { useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
export const Countdown: React.FC<{ durationInSeconds: number }> = ({
|
||||
durationInSeconds,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// remaining seconds, clamped at 0 — ceil so "1" shows for its full second
|
||||
const elapsed = frame / fps;
|
||||
const remaining = Math.max(0, Math.ceil(durationInSeconds - elapsed));
|
||||
|
||||
return <div style={{ fontVariantNumeric: "tabular-nums" }}>{formatMMSS(remaining)}</div>;
|
||||
};
|
||||
```
|
||||
|
||||
`durationInSeconds` is a **prop**, not a constant — the same composition renders a 5-minute stream timer or a 30-second sale timer by changing one input. Set the composition's `durationInFrames` to `durationInSeconds * fps` so the video is exactly as long as the countdown.
|
||||
|
||||
## Web timer (live page, requestAnimationFrame)
|
||||
|
||||
For a live page, drive an rAF loop from `performance.now()` and only repaint when the displayed second changes (no work 60×/sec for a 1 Hz number):
|
||||
|
||||
```js
|
||||
const start = performance.now();
|
||||
let lastShown = -1;
|
||||
function tick(now) {
|
||||
const remaining = Math.max(0, Math.ceil(duration - (now - start) / 1000));
|
||||
if (remaining !== lastShown) { render(remaining); lastShown = remaining; }
|
||||
if (remaining > 0) requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
```
|
||||
|
||||
Counting down to a fixed date is the same idea with `Date.now()`: `Math.max(0, target - Date.now())`. See `references/countdown-cookbook.md` for the full date-target version with timezone handling.
|
||||
|
||||
## Time formatting
|
||||
|
||||
Break a remaining-seconds integer into fields with division + modulo, then zero-pad. Pick the format to fit the duration — never show `00d 00h 05m` when minutes is all that matters.
|
||||
|
||||
```js
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
const formatMMSS = (s) => `${pad(Math.floor(s / 60))}:${pad(s % 60)}`;
|
||||
const formatDHMS = (s) => {
|
||||
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600);
|
||||
const m = Math.floor((s % 3600) / 60), sec = s % 60;
|
||||
return `${pad(d)}:${pad(h)}:${pad(m)}:${pad(sec)}`;
|
||||
};
|
||||
```
|
||||
|
||||
| Format | Use | Drop fields when |
|
||||
|---|---|---|
|
||||
| `mm:ss` | stream "starting soon", short timers (<1h) | — |
|
||||
| `hh:mm:ss` | flash sale, same-day event | hide `dd` while 0 |
|
||||
| `dd:hh:mm:ss` | launch / multi-day countdown | hide leading 0 units for cleaner read |
|
||||
|
||||
Always set `font-variant-numeric: tabular-nums` (or a monospace digit font) so digits keep equal width and the layout does not jitter as numbers change.
|
||||
|
||||
## Digit roll / flip transitions
|
||||
|
||||
A flat number swap reads as cheap; the rolling "odometer" look sells the countdown. Render each digit as a vertical strip 0–9 and translate it by `-digit × digitHeight`. Animate the *position*, not the text content, so the change is a glide, not a pop.
|
||||
|
||||
```css
|
||||
.reel { height: 1em; overflow: hidden; }
|
||||
.reel .col { transition: transform .45s cubic-bezier(.22,1,.36,1); }
|
||||
/* col holds stacked 0..9; shift by -value em */
|
||||
```
|
||||
|
||||
In Remotion, drive the strip offset off the frame instead of a CSS transition (so it is deterministic). The flip-card style (top half folds down over the bottom) and the per-digit reel are both detailed, with runnable components, in `references/digit-flip.md`.
|
||||
|
||||
## Looping background
|
||||
|
||||
Stream screens hold for minutes, so the background must loop seamlessly. Build motion on a cycle that divides evenly into the timeline (e.g. a gradient or particle drift whose period is `loopFrames`), and read it as `frame % loopFrames` so there is no visible seam at the wrap. Keep it low-contrast and slow — the number is the subject; the background is ambience. Recipes in `references/countdown-cookbook.md`.
|
||||
|
||||
## Output checklist
|
||||
|
||||
- Remaining time recomputed from `useCurrentFrame()` / `performance.now()` every frame — no `setInterval` decrement.
|
||||
- Timer hits `00:00` on the exact intended frame; `durationInFrames === durationInSeconds × fps`.
|
||||
- Duration is an input prop, not a hardcoded constant.
|
||||
- Digits use `tabular-nums`; layout does not shift as numbers change.
|
||||
- Format matches the span (mm:ss vs dd:hh:mm:ss); leading zero units dropped when distracting.
|
||||
- Background loop period divides the timeline evenly; no seam at the wrap.
|
||||
|
||||
## Deliver & verify (rendered stills → MP4)
|
||||
|
||||
> **Packaged helper** (`scripts/`): tile your stills with `scripts/contact-sheet.sh sheet.png f-hook.png f-mid.png f-end.png`, then assert the encode with `scripts/probe-mp4.sh out.mp4 [WxH] [fps]`. See `scripts/README.md`.
|
||||
|
||||
The countdown ships as a Remotion composition (`<Composition>` + zod `schema` + `defaultProps`, `durationInSeconds` a prop) — the timer is a pure function of `useCurrentFrame()`, never `setInterval` / `Date.now()` / `Math.random()`. Set `durationInFrames = durationInSeconds * fps`. Deliverable = `out/*.mp4` + the project. 9:16 vertical (1080×1920) is the default.
|
||||
|
||||
**Verify loop — render stills → inspect → encode.** The whole point is frame-accuracy, so check the *digit value* at exact frames before encoding.
|
||||
|
||||
```bash
|
||||
# Stills at start / mid / end — WITH SHIPPED PROPS (the real duration you'll render)
|
||||
npx remotion still Countdown out/f-start.png --frame=0 --props='{"durationInSeconds":300}'
|
||||
npx remotion still Countdown out/f-mid.png --frame=N --props='{"durationInSeconds":300}'
|
||||
npx remotion still Countdown out/f-end.png --frame=L --props='{"durationInSeconds":300}' # L = durationInFrames-1
|
||||
|
||||
# Inspect each PNG — the digits must read EXACTLY the frame/fps math:
|
||||
# - at frame N, displayed = ceil(durationInSeconds - N/fps) (e.g. 300s @ 30fps, frame 4500 → 150s → "02:30")
|
||||
# - last frame reads 00:00 (or the end-state hold); no drift, no off-by-one second
|
||||
# - tabular-nums: layout does not jitter between frames
|
||||
# - 9:16 safe area: the timer + brand block sit clear of top ~12% and bottom ~20-35% UI and the right action rail
|
||||
|
||||
npx remotion render Countdown out/countdown.mp4 --props='{"durationInSeconds":300}' # encode once digits verify
|
||||
npx remotion render Countdown out/demo.gif --codec=gif # README proof clip
|
||||
```
|
||||
|
||||
Use `npx remotion compositions` to confirm `durationInFrames`/`fps`, then compute the frame for any second you want to check.
|
||||
|
||||
**Before you finish:**
|
||||
1. Stills render cleanly at frame 0, mid, and last — no errors.
|
||||
2. The digit value at frame N equals `ceil(durationInSeconds - N/fps)` exactly; last frame hits 00:00 on the intended frame (no drift / off-by-one).
|
||||
3. Timer + brand stay inside the 9:16 safe area at every checked frame.
|
||||
4. Frame-driven only — no `setInterval` / `Date.now()` / timers; `tabular-nums` so layout holds.
|
||||
5. Shipped `durationInSeconds` (not just `defaultProps`) renders correctly; MP4 encoded, GIF optional.
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/countdown-cookbook.md` — complete runnable Remotion `<Countdown>` (configurable duration, mm:ss / dd:hh:mm:ss, end-state hold), the count-down-to-a-fixed-date web component with timezone handling, a "starting soon" stream-screen layout (brand block + social handles + looping gradient background), and the seamless-loop background pattern.
|
||||
- `references/digit-flip.md` — two deterministic digit-animation components: the odometer reel (vertical 0–9 strip driven by frame) and the flip-card (top-half fold) effect, plus when each suits launch vs sale vs stream.
|
||||
@@ -0,0 +1,205 @@
|
||||
# Countdown Cookbook
|
||||
|
||||
Complete, runnable recipes. Every value is derived from an authoritative clock (frame or timestamp) so nothing drifts.
|
||||
|
||||
## 1. Configurable Remotion countdown (mm:ss + dd:hh:mm:ss)
|
||||
|
||||
A single composition that handles any duration and either format. Drop into `src/Countdown.tsx`.
|
||||
|
||||
```tsx
|
||||
import React from "react";
|
||||
import { useCurrentFrame, useVideoConfig, AbsoluteFill } from "remotion";
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
const formatTime = (totalSeconds: number, mode: "mm:ss" | "dd:hh:mm:ss") => {
|
||||
const s = Math.max(0, totalSeconds);
|
||||
if (mode === "mm:ss") {
|
||||
return `${pad(Math.floor(s / 60))}:${pad(s % 60)}`;
|
||||
}
|
||||
const d = Math.floor(s / 86400);
|
||||
const h = Math.floor((s % 86400) / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
// drop leading day field once it is zero, for a cleaner read
|
||||
return d > 0
|
||||
? `${pad(d)}d ${pad(h)}:${pad(m)}:${pad(sec)}`
|
||||
: `${pad(h)}:${pad(m)}:${pad(sec)}`;
|
||||
};
|
||||
|
||||
export type CountdownProps = {
|
||||
durationInSeconds: number;
|
||||
format?: "mm:ss" | "dd:hh:mm:ss";
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export const Countdown: React.FC<CountdownProps> = ({
|
||||
durationInSeconds,
|
||||
format = "mm:ss",
|
||||
label = "",
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// ceil so the last whole second ("1") is shown for its full duration,
|
||||
// and the display reaches "0" exactly on the final frame.
|
||||
const remaining = Math.max(0, Math.ceil(durationInSeconds - frame / fps));
|
||||
const ended = remaining === 0;
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
background: "#0b0d17",
|
||||
color: "#fff",
|
||||
fontFamily: "Inter, system-ui, sans-serif",
|
||||
}}
|
||||
>
|
||||
{label && (
|
||||
<div style={{ fontSize: 36, opacity: 0.7, marginBottom: 16, letterSpacing: 4 }}>
|
||||
{label.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 180,
|
||||
fontWeight: 800,
|
||||
fontVariantNumeric: "tabular-nums", // equal-width digits → no jitter
|
||||
lineHeight: 1,
|
||||
color: ended ? "#ff4d4d" : "#fff",
|
||||
}}
|
||||
>
|
||||
{ended ? formatTime(0, format) : formatTime(remaining, format)}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Register it so `durationInFrames` matches the countdown exactly — the video ends when the timer does:
|
||||
|
||||
```tsx
|
||||
import { Composition } from "remotion";
|
||||
import { Countdown } from "./Countdown";
|
||||
|
||||
const FPS = 30;
|
||||
const DURATION_SECONDS = 300; // 5-minute "starting soon" timer
|
||||
|
||||
export const RemotionRoot = () => (
|
||||
<Composition
|
||||
id="Countdown"
|
||||
component={Countdown}
|
||||
durationInFrames={DURATION_SECONDS * FPS}
|
||||
fps={FPS}
|
||||
width={1920}
|
||||
height={1080}
|
||||
defaultProps={{ durationInSeconds: DURATION_SECONDS, format: "mm:ss", label: "Starting soon" }}
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
Render any duration/format without touching code — duration is a prop:
|
||||
|
||||
```bash
|
||||
npx remotion render Countdown out/sale.mp4 \
|
||||
--props='{"durationInSeconds":86400,"format":"dd:hh:mm:ss","label":"Sale ends in"}'
|
||||
```
|
||||
|
||||
To hold a final state (e.g. "WE'RE LIVE") instead of ending on `00:00`, extend `durationInFrames` past the countdown and branch on `ended`.
|
||||
|
||||
## 2. Count down to a fixed date (live web component)
|
||||
|
||||
For a real-time page that targets a calendar moment. Drift-free because remaining time is `target − Date.now()` recomputed each frame, never a decrement.
|
||||
|
||||
```js
|
||||
// targetISO must include an offset, e.g. "2026-07-01T09:00:00-07:00",
|
||||
// so the deadline is unambiguous regardless of the viewer's timezone.
|
||||
function startCountdown(targetISO, el) {
|
||||
const target = new Date(targetISO).getTime();
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
let lastShown = -1;
|
||||
|
||||
function tick() {
|
||||
const remainingMs = Math.max(0, target - Date.now());
|
||||
const s = Math.floor(remainingMs / 1000);
|
||||
if (s !== lastShown) {
|
||||
const d = Math.floor(s / 86400);
|
||||
const h = Math.floor((s % 86400) / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
el.textContent = `${pad(d)}:${pad(h)}:${pad(m)}:${pad(s % 60)}`;
|
||||
lastShown = s;
|
||||
}
|
||||
if (remainingMs > 0) requestAnimationFrame(tick);
|
||||
else el.textContent = "00:00:00:00"; // or swap to an "It's live" state
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
```
|
||||
|
||||
Timezone notes:
|
||||
- Bake the offset into the target string (`-07:00`, `Z`). A bare `"2026-07-01T09:00:00"` is parsed as the viewer's *local* time and every viewer sees a different deadline.
|
||||
- To show "ends 9am in *your* timezone everywhere," instead pick the wall-clock time per locale and let each viewer's `Date` resolve it — but that is a different deadline per region. Decide which you want before building.
|
||||
|
||||
## 3. "Starting soon" stream screen
|
||||
|
||||
Livestream lead-in screens hold 5–10 minutes: a clean brand block, social handles, and a calm looping background, with the timer as the focal point. Keep it low-clutter — chat and music carry the wait; the screen just signals "we're about to start."
|
||||
|
||||
```tsx
|
||||
import { AbsoluteFill, useCurrentFrame, useVideoConfig, interpolate } from "remotion";
|
||||
|
||||
export const StartingSoon: React.FC<{ durationInSeconds: number; handle: string; brand: string }> = ({
|
||||
durationInSeconds,
|
||||
handle,
|
||||
brand,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, durationInFrames } = useVideoConfig();
|
||||
const remaining = Math.max(0, Math.ceil(durationInSeconds - frame / fps));
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
// seamless looping gradient: angle cycles over loopFrames with no seam
|
||||
const loopFrames = fps * 8;
|
||||
const angle = interpolate(frame % loopFrames, [0, loopFrames], [0, 360]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `linear-gradient(${angle}deg, #141a3a, #0b0d17 60%)`,
|
||||
color: "#fff",
|
||||
fontFamily: "Inter, system-ui, sans-serif",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 64, fontWeight: 800, marginBottom: 8 }}>{brand}</div>
|
||||
<div style={{ fontSize: 28, opacity: 0.6, letterSpacing: 6, marginBottom: 40 }}>
|
||||
STARTING SOON
|
||||
</div>
|
||||
<div style={{ fontSize: 200, fontWeight: 800, fontVariantNumeric: "tabular-nums", lineHeight: 1 }}>
|
||||
{pad(Math.floor(remaining / 60))}:{pad(remaining % 60)}
|
||||
</div>
|
||||
<div style={{ position: "absolute", bottom: 60, fontSize: 30, opacity: 0.7 }}>{handle}</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Export as MP4 and add it as a Media/Video source in OBS, or use a looping playout for a browser source. Common durations: 300s (5 min) or 600s (10 min) at 1920×1080.
|
||||
|
||||
## 4. Seamless looping background
|
||||
|
||||
Any motion whose period divides the timeline evenly wraps without a seam. Read animation phase as `frame % loopFrames` so the value at the wrap equals the value at frame 0.
|
||||
|
||||
```tsx
|
||||
const loopFrames = fps * 6; // one full cycle every 6 seconds
|
||||
const phase = (frame % loopFrames) / loopFrames; // 0 → 1, repeats
|
||||
const driftY = Math.sin(phase * Math.PI * 2) * 40; // returns to start each cycle
|
||||
```
|
||||
|
||||
Use sine/cosine (inherently periodic) or `interpolate(frame % loopFrames, [0, loopFrames], [a, a])` with matching start/end so there is no jump. Keep contrast low and motion slow — the number is the subject.
|
||||
|
||||
---
|
||||
## Built by the team behind iart.ai
|
||||
|
||||
This skill is part of an open motion-graphics collection from iart.ai — the AI motion agent that turns data, scripts, and designs into editable motion graphics (Remotion → MP4). If you'd rather not hand-build this, iart.ai can render countdown videos from a template with a set time and brand — change the text/data and re-export. → [iart.ai](https://iart.ai/?utm_source=github&utm_medium=reference&utm_campaign=tiktok-video-skills&utm_content=ref_footer&utm_term=countdown-video)
|
||||
@@ -0,0 +1,146 @@
|
||||
# Digit Flip & Roll Transitions
|
||||
|
||||
Two ways to animate a digit changing. Both are driven by the frame (deterministic) so they render identically every time — no CSS transition clock that the renderer can disagree with.
|
||||
|
||||
Pick by tone:
|
||||
|
||||
| Effect | Feel | Best for |
|
||||
|---|---|---|
|
||||
| Odometer reel | smooth, continuous glide | launch countdowns, premium/minimal timers |
|
||||
| Flip card | mechanical "snap", split-flap board | sale urgency, retro/airport-board style |
|
||||
|
||||
## Shared idea
|
||||
|
||||
A single displayed digit is a stack of glyphs `0..9` laid out vertically. Showing digit `d` means translating the stack so glyph `d` sits in the window: `translateY(-d × digitHeight)`. Animate the *offset*, not the text, and you get a roll. The trick for a countdown is that the offset is continuous: between whole seconds the digit eases from its old value toward its new one.
|
||||
|
||||
## Odometer reel (Remotion, frame-driven)
|
||||
|
||||
```tsx
|
||||
import React from "react";
|
||||
import { useCurrentFrame, useVideoConfig, interpolate, Easing } from "remotion";
|
||||
|
||||
const DIGIT_H = 160; // px per glyph
|
||||
const ROLL_FRAMES = 12; // how long a single digit roll takes
|
||||
|
||||
// Animated single digit: rolls from `prev` to `value` over the last ROLL_FRAMES
|
||||
// of the current second.
|
||||
const Reel: React.FC<{ value: number; prev: number; secondProgress: number }> = ({
|
||||
value,
|
||||
prev,
|
||||
secondProgress,
|
||||
}) => {
|
||||
// secondProgress: 0→1 across the current whole second.
|
||||
// Start the roll near the end of the second so the number "settles" on change.
|
||||
const t = interpolate(secondProgress, [0.7, 1], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
easing: Easing.bezier(0.22, 1, 0.36, 1),
|
||||
});
|
||||
// continuous position between prev and value
|
||||
const shown = prev + (value - prev) * t;
|
||||
const offset = -shown * DIGIT_H;
|
||||
|
||||
return (
|
||||
<div style={{ height: DIGIT_H, width: 110, overflow: "hidden", position: "relative" }}>
|
||||
<div style={{ transform: `translateY(${offset}px)` }}>
|
||||
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => (
|
||||
<div
|
||||
key={n}
|
||||
style={{
|
||||
height: DIGIT_H,
|
||||
lineHeight: `${DIGIT_H}px`,
|
||||
textAlign: "center",
|
||||
fontSize: 140,
|
||||
fontWeight: 800,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{n}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Renders an mm:ss reel where each digit rolls.
|
||||
export const ReelClock: React.FC<{ durationInSeconds: number }> = ({ durationInSeconds }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const elapsed = frame / fps;
|
||||
const remaining = Math.max(0, Math.ceil(durationInSeconds - elapsed));
|
||||
const prevRemaining = Math.max(0, remaining + 1); // value one second ago
|
||||
const secondProgress = elapsed % 1; // 0→1 within the current second
|
||||
|
||||
const digits = (s: number) =>
|
||||
[Math.floor(s / 600) % 10, Math.floor(s / 60) % 10, Math.floor(s / 10) % 10, s % 10];
|
||||
|
||||
const cur = digits(remaining);
|
||||
const prev = digits(prevRemaining);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 8, color: "#fff", background: "#0b0d17", padding: 40 }}>
|
||||
{cur.map((d, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i === 2 && <div style={{ fontSize: 140, fontWeight: 800 }}>:</div>}
|
||||
<Reel value={d} prev={prev[i]} secondProgress={secondProgress} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Each digit only rolls when it actually changes; a digit whose `prev === value` sits still because `shown` stays constant.
|
||||
- A `9 → 0` rollover rolls *backward* through the strip (the cheap-looking part). To make tens/units roll the natural way on a carry, render an extra `0` glyph below the `9` and clamp — covered as an exercise; for most timers the backward roll at the tens boundary is unnoticeable at speed.
|
||||
- `ROLL_FRAMES` is unused above because the roll is tied to `secondProgress`; expose it instead if a fixed-length roll is preferred.
|
||||
|
||||
## Flip card (split-flap)
|
||||
|
||||
The top half of the old digit folds down over a static bottom half, revealing the new digit. Pure CSS keyframe version — fine for a live web page; for Remotion, drive the `rotateX` off `secondProgress` the same way as the reel so it stays deterministic.
|
||||
|
||||
```css
|
||||
.flip { position: relative; width: 110px; height: 160px; perspective: 600px; }
|
||||
.flip .card {
|
||||
position: absolute; inset: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 140px; font-weight: 800; font-variant-numeric: tabular-nums;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
.flip .top { clip-path: inset(0 0 50% 0); transform-origin: bottom; }
|
||||
.flip .bottom { clip-path: inset(50% 0 0 0); }
|
||||
/* on change, animate the leaf folding down */
|
||||
.flip .leaf {
|
||||
position: absolute; inset: 0; transform-origin: bottom;
|
||||
animation: fold .45s cubic-bezier(.36,0,.66,-0.2) forwards;
|
||||
}
|
||||
@keyframes fold {
|
||||
0% { transform: rotateX(0deg); }
|
||||
100% { transform: rotateX(-90deg); }
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// swap the digit and replay the fold whenever the value changes
|
||||
function setFlip(el, newDigit) {
|
||||
if (el.dataset.v === String(newDigit)) return; // no change → no flip
|
||||
el.dataset.v = String(newDigit);
|
||||
el.querySelector(".top").textContent = newDigit;
|
||||
el.querySelector(".bottom").textContent = newDigit;
|
||||
const leaf = el.querySelector(".leaf");
|
||||
leaf.textContent = newDigit;
|
||||
leaf.style.animation = "none";
|
||||
void leaf.offsetWidth; // reflow → restart the animation
|
||||
leaf.style.animation = "";
|
||||
}
|
||||
```
|
||||
|
||||
Drive `setFlip` from the same `requestAnimationFrame` + `performance.now()` loop in the cookbook (only call it when the displayed second changes), so the flips fire on real time and never drift.
|
||||
|
||||
## Choosing
|
||||
|
||||
- **Launch / premium:** odometer reel, slow ease, large tabular digits, muted palette.
|
||||
- **Flash sale:** flip card for the mechanical urgency, or reel with a faster roll and a hot accent color on `mm:ss`.
|
||||
- **Stream "starting soon":** either works, but keep it calm — a single reel on `mm:ss` reads better than a busy split-flap for a 5–10 minute hold.
|
||||
Reference in new issue
Block a user