Files
cycle-atlas/.agents/skills/countdown-video/references/digit-flip.md
T

147 lines
5.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.