Remotion LabRemotion Lab
返回模板庫

X / Twitter 語錄卡

忠實還原 X(Twitter)暗色推文卡,含逐行淡入語錄、數字滾動統計與頭像滑入動畫。

社群正方文字
提示詞(可直接修改內容)
import {
  AbsoluteFill,
  interpolate,
  spring,
  useCurrentFrame,
  useVideoConfig,
} from "remotion";
import React from "react";

const DISPLAY-NAME = "工程師 Kai";
const HANDLE = "@kai-engineer";
const QUOTE = "寫程式不是為了讓電腦\n理解,而是為了讓人\n能夠閱讀。";
const LIKES = 2400;
const RETWEETS = 891;
const VIEWS = 48000;

function formatCount(n: number): string {
  if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, "") + "K";
  return String(n);
}

const QUOTE-LINES = QUOTE.split("\n");

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

  // Card scale + fade in: frames 0-20
  const cardProgress = spring({
    frame,
    fps,
    config: { damping: 22, stiffness: 140 },
    durationInFrames: 20,
  });
  const cardScale = interpolate(cardProgress, [0, 1], [0.9, 1]);
  const cardOpacity = interpolate(cardProgress, [0, 0.3], [0, 1], {
    extrapolateRight: "clamp",
  });

  // Avatar + name slide from top: frames 10-25
  const headerProgress = spring({
    frame: frame - 10,
    fps,
    config: { damping: 20, stiffness: 180 },
    durationInFrames: 15,
  });
  const headerY = interpolate(headerProgress, [0, 1], [-30, 0]);
  const headerOpacity = interpolate(headerProgress, [0, 0.4], [0, 1], {
    extrapolateRight: "clamp",
  });

  // Quote lines staggered fade in: frames 20-50
  const lineOpacities = QUOTE-LINES.map((_, i) => {
    const start = 20 + i * 10;
    const lineProgress = spring({
      frame: frame - start,
      fps,
      config: { damping: 25, stiffness: 150 },
      durationInFrames: 12,
    });
    return interpolate(lineProgress, [0, 0.5], [0, 1], {
      extrapolateRight: "clamp",
    });
  });

  const lineTranslations = QUOTE-LINES.map((_, i) => {
    const start = 20 + i * 10;
    const lineProgress = spring({
      frame: frame - start,
      fps,
      config: { damping: 25, stiffness: 150 },
      durationInFrames: 12,
    });
    return interpolate(lineProgress, [0, 1], [12, 0]);
  });

  // Stats count up: frames 40-80
  const statsRaw = interpolate(frame, [40, 80], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });
  const currentLikes = Math.round(statsRaw * LIKES);
  const currentRetweets = Math.round(statsRaw * RETWEETS);
  const currentViews = Math.round(statsRaw * VIEWS);

  // Timestamp fade in: frames 60-75
  const tsOpacity = interpolate(frame, [60, 75], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });

  return (
    <AbsoluteFill
      style={{
        background: "#000000",
        justifyContent: "center",
        alignItems: "center",
      }}
    >
      {/* Card */}
      <div
        style={{
          transform: `scale(${cardScale})`,
          opacity: cardOpacity,
          background: "#0f0f0f",
          border: "1px solid #2f3336",
          borderRadius: 20,
          padding: "44px 52px",
          width: 860,
          boxSizing: "border-box",
          fontFamily: "sans-serif",
        }}
      >
        {/* Header: avatar + name + handle + checkmark */}
        <div
          style={{
            transform: `translateY(${headerY}px)`,
            opacity: headerOpacity,
            display: "flex",
            alignItems: "center",
            gap: 16,
            marginBottom: 28,
          }}
        >
          {/* Avatar */}
          <div
            style={{
              width: 56,
              height: 56,
              borderRadius: "50%",
              background: "linear-gradient(135deg, #1d9bf0, #0553a1)",
              flexShrink: 0,
              display: "flex",
              alignItems: "center",
              justifyContent: "center",
              fontSize: 22,
              fontWeight: 700,
              color: "#ffffff",
            }}
          >
            K
          </div>

          {/* Name + handle */}
          <div style={{ flex: 1 }}>
            <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
              <span
                style={{
                  fontSize: 20,
                  fontWeight: 700,
                  color: "#ffffff",
                  lineHeight: 1.2,
                }}
              >
                {DISPLAY-NAME}
              </span>
              {/* Verified badge */}
              <div
                style={{
                  width: 22,
                  height: 22,
                  borderRadius: "50%",
                  background: "#1d9bf0",
                  display: "flex",
                  alignItems: "center",
                  justifyContent: "center",
                  fontSize: 13,
                  color: "#ffffff",
                  fontWeight: 700,
                  flexShrink: 0,
                }}
              >

              </div>
            </div>
            <div style={{ fontSize: 16, color: "#71767b", marginTop: 1 }}>
              {HANDLE}
            </div>
          </div>

          {/* X logo */}
          <div
            style={{
              fontSize: 26,
              color: "#ffffff",
              fontWeight: 900,
              letterSpacing: -1,
            }}
          >
            𝕏
          </div>
        </div>

        {/* Divider */}
        <div
          style={{
            height: 1,
            background: "#2f3336",
            marginBottom: 28,
            opacity: headerOpacity,
          }}
        />

        {/* Quote text */}
        <div style={{ marginBottom: 32 }}>
          {QUOTE-LINES.map((line, i) => (
            <div
              key={i}
              style={{
                fontSize: 38,
                fontWeight: 600,
                color: "#ffffff",
                lineHeight: 1.55,
                opacity: lineOpacities[i],
                transform: `translateY(${lineTranslations[i]}px)`,
              }}
            >
              {line}
            </div>
          ))}
        </div>

        {/* Timestamp */}
        <div
          style={{
            fontSize: 15,
            color: "#71767b",
            marginBottom: 20,
            opacity: tsOpacity,
          }}
        >
          下午 3:42 · 2025年4月9日
        </div>

        {/* Divider */}
        <div
          style={{
            height: 1,
            background: "#2f3336",
            marginBottom: 20,
            opacity: tsOpacity,
          }}
        />

        {/* Stats row */}
        <div
          style={{
            display: "flex",
            gap: 36,
            opacity: tsOpacity,
          }}
        >
          {[
            { icon: "↺", value: currentRetweets, label: "轉推" },
            { icon: "♡", value: currentLikes, label: "喜歡" },
            { icon: "👁", value: currentViews, label: "瀏覽" },
          ].map(({ icon, value, label }) => (
            <div
              key={label}
              style={{
                display: "flex",
                alignItems: "center",
                gap: 8,
                color: "#71767b",
                fontSize: 16,
              }}
            >
              <span style={{ fontSize: 20 }}>{icon}</span>
              <span style={{ fontWeight: 700, color: "#e7e9ea" }}>
                {formatCount(value)}
              </span>
              <span>{label}</span>
            </div>
          ))}
        </div>
      </div>
    </AbsoluteFill>
  );
};

登入後查看完整程式碼