Remotion LabRemotion Lab
返回模板庫

翻頁時鐘倒數計時器

模擬機械翻頁時鐘效果,數字切換時上半部向下翻轉(CSS perspective + rotateX),顯示 MM:SS 格式從 01:00 倒數至 00:00。

倒數翻頁時鐘機械計時器
提示詞(可直接修改內容)
import {
  AbsoluteFill,
  interpolate,
  useCurrentFrame,
  useVideoConfig,
} from "remotion";
import React from "react";

// 從 01:00 倒數至 00:00,共 60 秒
const TOTAL-SECONDS = 60;

function pad(n: number) {
  return String(n).padStart(2, "0");
}

interface FlipDigitProps {
  current: string;
  prev: string;
  isFlipping: boolean;
  flipProgress: number;
}

const FlipDigit: React.FC<FlipDigitProps> = ({
  current,
  prev,
  isFlipping,
  flipProgress,
}) => {
  const topFlipAngle = interpolate(flipProgress, [0, 0.5], [0, -90], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });
  const bottomFlipAngle = interpolate(flipProgress, [0.5, 1], [90, 0], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });
  const showNewTop = flipProgress >= 0.5;

  const cardW = 140;
  const cardH = 180;
  const halfH = cardH / 2;

  const cardStyle: React.CSSProperties = {
    width: cardW,
    height: cardH,
    borderRadius: 12,
    overflow: "hidden",
    position: "relative",
    boxShadow: "0 8px 32px rgba(0,0,0,0.6)",
    background: "#1a1a2e",
  };

  const digitStyle: React.CSSProperties = {
    position: "absolute",
    width: "100%",
    fontSize: 120,
    fontWeight: 900,
    color: "#f5f5f0",
    fontFamily: "monospace",
    textAlign: "center",
    lineHeight: `${cardH}px`,
    userSelect: "none",
  };

  return (
    <div style={cardStyle}>
      {/* 靜態下半(當前數字底部) */}
      <div
        style={{
          position: "absolute",
          top: halfH,
          left: 0,
          width: cardW,
          height: halfH,
          overflow: "hidden",
          borderTop: "1px solid rgba(0,0,0,0.4)",
          background: "#16213e",
          borderRadius: "0 0 12px 12px",
        }}
      >
        <div style={{ ...digitStyle, top: -halfH }}>{current}</div>
      </div>

      {/* 靜態上半 */}
      <div
        style={{
          position: "absolute",
          top: 0,
          left: 0,
          width: cardW,
          height: halfH,
          overflow: "hidden",
          background: "#1a1a2e",
          borderRadius: "12px 12px 0 0",
        }}
      >
        <div style={{ ...digitStyle, top: 0 }}>
          {showNewTop ? current : prev}
        </div>
      </div>

      {/* 翻轉牌片 — 上半翻下 */}
      {isFlipping && flipProgress < 0.5 && (
        <div
          style={{
            position: "absolute",
            top: 0,
            left: 0,
            width: cardW,
            height: halfH,
            overflow: "hidden",
            background: "#1a1a2e",
            borderRadius: "12px 12px 0 0",
            transformOrigin: "bottom center",
            transform: `perspective(600px) rotateX(${topFlipAngle}deg)`,
            zIndex: 10,
            backfaceVisibility: "hidden",
          }}
        >
          <div style={{ ...digitStyle, top: 0 }}>{prev}</div>
        </div>
      )}

      {/* 翻轉牌片 — 下半展開 */}
      {isFlipping && flipProgress >= 0.5 && (
        <div
          style={{
            position: "absolute",
            top: halfH,
            left: 0,
            width: cardW,
            height: halfH,
            overflow: "hidden",
            background: "#16213e",
            borderRadius: "0 0 12px 12px",
            transformOrigin: "top center",
            transform: `perspective(600px) rotateX(${bottomFlipAngle}deg)`,
            zIndex: 10,
            backfaceVisibility: "hidden",
          }}
        >
          <div style={{ ...digitStyle, top: -halfH }}>{current}</div>
        </div>
      )}

      {/* 中間分隔線 */}
      <div
        style={{
          position: "absolute",
          top: halfH - 1,
          left: 0,
          width: "100%",
          height: 2,
          background: "rgba(0,0,0,0.5)",
          zIndex: 20,
        }}
      />
    </div>
  );
};

interface FlipGroupProps {
  currentVal: string;
  prevVal: string;
  isFlipping: boolean;
  flipProgress: number;
}

const FlipGroup: React.FC<FlipGroupProps> = ({
  currentVal,
  prevVal,
  isFlipping,
  flipProgress,
}) => {
  return (
    <div style={{ display: "flex", gap: 6 }}>
      <FlipDigit
        current={currentVal[0]}
        prev={prevVal[0]}
        isFlipping={isFlipping && currentVal[0] !== prevVal[0]}
        flipProgress={flipProgress}
      />
      <FlipDigit
        current={currentVal[1]}
        prev={prevVal[1]}
        isFlipping={isFlipping && currentVal[1] !== prevVal[1]}
        flipProgress={flipProgress}
      />
    </div>
  );
};

export const CountdownFlipClock: React.FC = () => {
  const frame = useCurrentFrame();
  const { fps, durationInFrames } = useVideoConfig();

  const totalFrames = durationInFrames;
  const framesPerSecond = totalFrames / TOTAL-SECONDS;

  const elapsed = (frame / totalFrames) * TOTAL-SECONDS;
  const currentSecTotal = Math.max(0, TOTAL-SECONDS - Math.floor(elapsed));
  const prevSecTotal = Math.min(TOTAL-SECONDS, currentSecTotal + 1);

  const currentMin = Math.floor(currentSecTotal / 60);
  const currentSec = currentSecTotal % 60;
  const prevMin = Math.floor(prevSecTotal / 60);
  const prevSec = prevSecTotal % 60;

  const currentMinStr = pad(currentMin);
  const currentSecStr = pad(currentSec);
  const prevMinStr = pad(prevMin);
  const prevSecStr = pad(prevSec);

  const frameWithinSecond = frame % framesPerSecond;
  const flipDuration = framesPerSecond * 0.4;
  const isFlipping = frameWithinSecond < flipDuration && frame > 0;
  const flipProgress = isFlipping
    ? interpolate(frameWithinSecond, [0, flipDuration], [0, 1], {
        extrapolateRight: "clamp",
      })
    : frame === 0
    ? 0
    : 1;

  const secIsFlipping = isFlipping && currentSecStr !== prevSecStr;
  const minIsFlipping = isFlipping && currentMinStr !== prevMinStr;

  return (
    <AbsoluteFill
      style={{
        background: "linear-gradient(160deg, #0d0d1a 0%, #1a1030 100%)",
        justifyContent: "center",
        alignItems: "center",
      }}
    >
      {/* 裝飾光暈 */}
      <div
        style={{
          position: "absolute",
          width: 800,
          height: 400,
          borderRadius: "50%",
          background:
            "radial-gradient(ellipse, rgba(99,102,241,0.12) 0%, transparent 70%)",
        }}
      />

      {/* 標題 */}
      <div
        style={{
          position: "absolute",
          top: 200,
          fontSize: 36,
          fontWeight: 300,
          color: "rgba(255,255,255,0.4)",
          fontFamily: "sans-serif",
          letterSpacing: 12,
          textTransform: "uppercase",
        }}
      >
        倒數計時
      </div>

      {/* 翻頁時鐘主體 */}
      <div
        style={{
          display: "flex",
          alignItems: "center",
          gap: 32,
        }}
      >
        {/* 分鐘 */}
        <div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 12 }}>
          <FlipGroup
            currentVal={currentMinStr}
            prevVal={prevMinStr}
            isFlipping={minIsFlipping}
            flipProgress={flipProgress}
          />
          <div
            style={{
              fontSize: 20,
              color: "rgba(255,255,255,0.3)",
              fontFamily: "sans-serif",
              letterSpacing: 6,
            }}
          >

          </div>
        </div>

        {/* 冒號分隔 */}
        <div
          style={{
            display: "flex",
            flexDirection: "column",
            gap: 24,
            paddingBottom: 32,
          }}
        >
          {[0, 1].map((i) => (
            <div
              key={i}
              style={{
                width: 16,
                height: 16,
                borderRadius: "50%",
                background: interpolate(
                  Math.sin(frame * 0.15),
                  [-1, 1],
                  [0.3, 1]
                ) > 0.6
                  ? "#6366f1"
                  : "rgba(99,102,241,0.3)",
                boxShadow: "0 0 8px rgba(99,102,241,0.6)",
              }}
            />
          ))}
        </div>

        {/* 秒 */}
        <div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 12 }}>
          <FlipGroup
            currentVal={currentSecStr}
            prevVal={prevSecStr}
            isFlipping={secIsFlipping}
            flipProgress={flipProgress}
          />
          <div
            style={{
              fontSize: 20,
              color: "rgba(255,255,255,0.3)",
              fontFamily: "sans-serif",
              letterSpacing: 6,
            }}
          >

          </div>
        </div>
      </div>

      {/* 底部陰影裝飾 */}
      <div
        style={{
          position: "absolute",
          bottom: 180,
          display: "flex",
          gap: 32,
        }}
      >
        {["分", "秒"].map((label) => (
          <div
            key={label}
            style={{
              width: 296,
              height: 12,
              background: "rgba(0,0,0,0.4)",
              borderRadius: "50%",
              filter: "blur(6px)",
            }}
          />
        ))}
      </div>
    </AbsoluteFill>
  );
};

登入後查看完整程式碼