X / Twitter 動態時間軸
四則推文由下往上交錯滑入,每張深色卡片含頭像、認證徽章、推文內容與互動數據,數字在卡片入場後動態計數。
社群簡約橫式
提示詞(可直接修改內容)
import {
AbsoluteFill,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
} from "remotion";
import React from "react";
const TWEETS = [
{
name: "工程師 Kai",
handle: "@kai-dev",
time: "2h",
verified: true,
text: "剛發現 Remotion 可以用 React 寫影片,這真的太酷了!整個開啟新世界的感覺 🚀",
replies: 24,
retweets: 187,
likes: 1423,
views: 28400,
},
{
name: "設計師 Mei",
handle: "@mei-design",
time: "4h",
verified: false,
text: "今天做了一個動態資訊圖表,用 Remotion + React 大概花了 2 小時。以前 After Effects 要花 8 小時 😅",
replies: 31,
retweets: 256,
likes: 2187,
views: 41200,
},
{
name: "前端週報",
handle: "@frontend-weekly",
time: "6h",
verified: true,
text: "本週熱門:#Remotion 3.0 正式發布!支援 Lambda 渲染、React 19、以及全新的 CLI 工具。",
replies: 18,
retweets: 423,
likes: 3241,
views: 89000,
},
{
name: "Open Source TW",
handle: "@opensource-tw",
time: "8h",
verified: false,
text: "推薦給所有創作者:@remotion 讓影片製作變得像寫程式一樣。GitHub 已破 20k ⭐",
replies: 12,
retweets: 98,
likes: 876,
views: 15600,
},
];
// Avatar gradient colours per tweet (module scope)
const AVATAR-GRADIENTS = [
"linear-gradient(135deg, #1d9bf0, #0553a1)",
"linear-gradient(135deg, #f093fb, #f5576c)",
"linear-gradient(135deg, #43e97b, #38f9d7)",
"linear-gradient(135deg, #fa709a, #fee140)",
];
// Avatar initials (module scope)
const AVATAR-INITIALS = ["K", "M", "週", "O"];
function formatCount(n: number): string {
if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, "") + "K";
return String(n);
}
// Card layout constants
const CARD-WIDTH = 860;
const CARD-GAP = 14;
const CARD-HEIGHT = 178;
const TOTAL-HEIGHT =
TWEETS.length * CARD-HEIGHT + (TWEETS.length - 1) * CARD-GAP;
const CARDS-TOP = (1080 - TOTAL-HEIGHT) / 2;
export const TwitterFeed: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Per-card slide-up spring
const cardProgressList = TWEETS.map((_, i) => {
const startFrame = i * 18 + 5;
return spring({
frame: frame - startFrame,
fps,
config: { damping: 22, stiffness: 150 },
durationInFrames: 20,
});
});
// Stats count-up after each card appears (starts ~18 frames after card slide-in)
const statsProgressList = TWEETS.map((_, i) => {
const start = i * 18 + 5 + 18;
return interpolate(frame, [start, start + 35], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
});
return (
<AbsoluteFill
style={{
background: "#0f0f0f",
fontFamily: "sans-serif",
alignItems: "center",
}}
>
{/* X brand mark top-right */}
<div
style={{
position: "absolute",
top: 40,
right: 80,
fontSize: 44,
color: "rgba(255,255,255,0.15)",
fontWeight: 900,
}}
>
𝕏
</div>
{/* Tweet cards */}
{TWEETS.map((tweet, i) => {
const progress = cardProgressList[i];
const statsProgress = statsProgressList[i];
const translateY = interpolate(progress, [0, 1], [60, 0]);
const opacity = interpolate(progress, [0, 0.35], [0, 1], {
extrapolateRight: "clamp",
});
const cardTop = CARDS-TOP + i * (CARD-HEIGHT + CARD-GAP);
const currentLikes = Math.round(statsProgress * tweet.likes);
const currentRetweets = Math.round(statsProgress * tweet.retweets);
const currentViews = Math.round(statsProgress * tweet.views);
return (
<div
key={i}
style={{
position: "absolute",
top: cardTop,
width: CARD-WIDTH,
background: "#16181c",
border: "1px solid #2f3336",
borderRadius: 16,
padding: "20px 28px",
boxSizing: "border-box",
transform: `translateY(${translateY}px)`,
opacity,
}}
>
{/* Header row: avatar + name + handle + timestamp */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 14,
marginBottom: 12,
}}
>
{/* Avatar */}
<div
style={{
width: 44,
height: 44,
borderRadius: "50%",
background: AVATAR-GRADIENTS[i],
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 17,
fontWeight: 700,
color: "#ffffff",
}}
>
{AVATAR-INITIALS[i]}
</div>
{/* Name + handle */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
flexWrap: "nowrap",
}}
>
<span
style={{
fontSize: 17,
fontWeight: 700,
color: "#e7e9ea",
lineHeight: 1.2,
whiteSpace: "nowrap",
}}
>
{tweet.name}
</span>
{tweet.verified && (
<div
style={{
width: 18,
height: 18,
borderRadius: "50%",
background: "#1d9bf0",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 11,
color: "#ffffff",
fontWeight: 700,
flexShrink: 0,
}}
>
✓
</div>
)}
<span
style={{
fontSize: 15,
color: "#71767b",
whiteSpace: "nowrap",
}}
>
{tweet.handle} · {tweet.time}
</span>
</div>
</div>
{/* X logo */}
<div
style={{
fontSize: 20,
color: "rgba(255,255,255,0.3)",
fontWeight: 900,
flexShrink: 0,
}}
>
𝕏
</div>
</div>
{/* Tweet text */}
<div
style={{
fontSize: 17,
color: "#e7e9ea",
lineHeight: 1.5,
marginBottom: 14,
}}
>
{tweet.text}
</div>
{/* Stats row */}
<div
style={{
display: "flex",
gap: 0,
alignItems: "center",
borderTop: "1px solid #2f3336",
paddingTop: 12,
}}
>
{[
{ icon: "💬", value: tweet.replies, label: "" },
{ icon: "🔁", value: currentRetweets, label: "" },
{ icon: "♡", value: currentLikes, label: "" },
{ icon: "📊", value: currentViews, label: "" },
].map(({ icon, value }, j) => (
<div
key={j}
style={{
display: "flex",
alignItems: "center",
gap: 6,
color: "#71767b",
fontSize: 14,
flex: 1,
}}
>
<span style={{ fontSize: 16 }}>{icon}</span>
<span style={{ fontWeight: 600, color: "#8b949e" }}>
{formatCount(value)}
</span>
</div>
))}
</div>
</div>
);
})}
</AbsoluteFill>
);
};登入後查看完整程式碼