YouTube 影片牆首頁
仿 YouTube 首頁的 3×2 影片卡片格局,每張卡片含 16:9 漸層縮圖、播放按鈕、時長標籤、頻道頭像、標題與觀看數,依序縮放彈入並動態計數。
社群簡約橫式
提示詞(可直接修改內容)
import {
AbsoluteFill,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
} from "remotion";
import React from "react";
const VIDEOS = [
{
gradient: "linear-gradient(135deg, #1a1a2e, #16213e)",
title: "用 React 做動畫影片?Remotion 完整教學",
channel: "工程師 Kai",
views: "12萬",
viewsNum: 120000,
time: "3天前",
duration: "18:24",
},
{
gradient: "linear-gradient(135deg, #0f3460, #533483)",
title: "2025 前端必學技術清單(附學習路線)",
channel: "前端週報",
views: "8.4萬",
viewsNum: 84000,
time: "1週前",
duration: "24:11",
},
{
gradient: "linear-gradient(135deg, #2d1b69, #11998e)",
title: "TypeScript 進階技巧 - 你不知道的 10 個用法",
channel: "TS 大師",
views: "23萬",
viewsNum: 230000,
time: "2週前",
duration: "31:05",
},
{
gradient: "linear-gradient(135deg, #1a0533, #4a0080)",
title: "從零打造個人品牌 - 軟體工程師版",
channel: "職涯設計師",
views: "5.2萬",
viewsNum: 52000,
time: "4天前",
duration: "42:18",
},
{
gradient: "linear-gradient(135deg, #003973, #e5e5be)",
title: "React 19 新功能完整解析",
channel: "React 台灣",
views: "31萬",
viewsNum: 310000,
time: "剛上傳",
duration: "28:33",
},
{
gradient: "linear-gradient(135deg, #200122, #6f0000)",
title: "Side Project 到底能不能賺錢?真實案例分享",
channel: "創業者日記",
views: "18萬",
viewsNum: 180000,
time: "5天前",
duration: "15:47",
},
];
// Channel avatar colours at module scope
const CHANNEL-COLORS = [
"#1d9bf0",
"#f5576c",
"#43e97b",
"#fa709a",
"#e5e5be",
"#ff9a9e",
];
const CHANNEL-INITIALS = ["K", "週", "T", "職", "R", "創"];
const COLS = 3;
const ROWS = 2;
const CARD-W = 580;
const THUMB-H = Math.round((CARD-W * 9) / 16); // 326
const CARD-INFO-H = 100;
const CARD-H = THUMB-H + CARD-INFO-H;
const CARD-GAP = 24;
const GRID-TOTAL-W = COLS * CARD-W + (COLS - 1) * CARD-GAP;
const GRID-TOTAL-H = ROWS * CARD-H + (ROWS - 1) * CARD-GAP;
const GRID-LEFT = (1920 - GRID-TOTAL-W) / 2;
// Leave room for header (72px)
const GRID-TOP = 80 + (1080 - 80 - GRID-TOTAL-H) / 2;
// Pre-compute card positions
const CARD-POSITIONS = VIDEOS.map((_, i) => ({
col: i % COLS,
row: Math.floor(i / COLS),
x: GRID-LEFT + (i % COLS) * (CARD-W + CARD-GAP),
y: GRID-TOP + Math.floor(i / COLS) * (CARD-H + CARD-GAP),
}));
function formatViewCount(n: number, progress: number): string {
const v = Math.round(progress * n);
if (v >= 10000) return (v / 10000).toFixed(1).replace(/\.0$/, "") + "萬";
return v.toLocaleString();
}
export const YoutubeFeed: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Header fade in
const headerOpacity = interpolate(frame, [0, 18], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
// Per-card spring
const cardProgressList = VIDEOS.map((_, i) => {
const startFrame = i * 10 + 5;
return spring({
frame: frame - startFrame,
fps,
config: { damping: 20, stiffness: 150 },
durationInFrames: 18,
});
});
// View count animate up after card appears
const viewProgressList = VIDEOS.map((_, i) => {
const start = i * 10 + 5 + 15;
return interpolate(frame, [start, start + 30], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
});
return (
<AbsoluteFill
style={{ background: "#0f0f0f", fontFamily: "sans-serif" }}
>
{/* YouTube-style header */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: 72,
display: "flex",
alignItems: "center",
paddingLeft: GRID-LEFT,
paddingRight: GRID-LEFT,
gap: 16,
opacity: headerOpacity,
borderBottom: "1px solid rgba(255,255,255,0.07)",
}}
>
{/* YouTube logo (text version) */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
}}
>
<div
style={{
background: "#ff0000",
borderRadius: 6,
width: 38,
height: 26,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div
style={{
width: 0,
height: 0,
borderTop: "7px solid transparent",
borderBottom: "7px solid transparent",
borderLeft: "12px solid #ffffff",
marginLeft: 2,
}}
/>
</div>
<span
style={{
fontSize: 22,
fontWeight: 700,
color: "#ffffff",
letterSpacing: -0.5,
}}
>
YouTube
</span>
</div>
<div style={{ flex: 1 }} />
{/* Search bar placeholder */}
<div
style={{
background: "#121212",
border: "1px solid #303030",
borderRadius: 20,
padding: "8px 24px",
fontSize: 15,
color: "#606060",
width: 340,
}}
>
搜尋
</div>
</div>
{/* Video cards */}
{VIDEOS.map((video, i) => {
const { x, y } = CARD-POSITIONS[i];
const progress = cardProgressList[i];
const viewProgress = viewProgressList[i];
const scale = interpolate(progress, [0, 1], [0.88, 1], {
extrapolateRight: "clamp",
});
const opacity = interpolate(progress, [0, 0.35], [0, 1], {
extrapolateRight: "clamp",
});
return (
<div
key={i}
style={{
position: "absolute",
left: x,
top: y,
width: CARD-W,
transform: `scale(${scale})`,
opacity,
transformOrigin: "center top",
}}
>
{/* Thumbnail */}
<div
style={{
width: CARD-W,
height: THUMB-H,
borderRadius: 10,
background: video.gradient,
position: "relative",
overflow: "hidden",
}}
>
{/* Subtle grid overlay */}
<div
style={{
position: "absolute",
inset: 0,
backgroundImage:
"repeating-linear-gradient(0deg, rgba(255,255,255,0.03) 0px, rgba(255,255,255,0.03) 1px, transparent 1px, transparent 50px), repeating-linear-gradient(90deg, rgba(255,255,255,0.03) 0px, rgba(255,255,255,0.03) 1px, transparent 1px, transparent 50px)",
}}
/>
{/* Play button overlay */}
<div
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 52,
height: 52,
borderRadius: "50%",
background: "rgba(0,0,0,0.65)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div
style={{
width: 0,
height: 0,
borderTop: "10px solid transparent",
borderBottom: "10px solid transparent",
borderLeft: "18px solid #ffffff",
marginLeft: 4,
}}
/>
</div>
{/* Duration badge */}
<div
style={{
position: "absolute",
bottom: 8,
right: 8,
background: "rgba(0,0,0,0.8)",
borderRadius: 4,
padding: "3px 7px",
fontSize: 13,
fontWeight: 700,
color: "#ffffff",
}}
>
{video.duration}
</div>
</div>
{/* Card info */}
<div
style={{
display: "flex",
gap: 12,
paddingTop: 12,
}}
>
{/* Channel avatar */}
<div
style={{
width: 36,
height: 36,
borderRadius: "50%",
background: CHANNEL-COLORS[i],
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 14,
fontWeight: 700,
color: "#ffffff",
marginTop: 2,
}}
>
{CHANNEL-INITIALS[i]}
</div>
{/* Title + channel + meta */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 15,
fontWeight: 700,
color: "#ffffff",
lineHeight: 1.4,
marginBottom: 5,
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{video.title}
</div>
<div style={{ fontSize: 13, color: "#aaaaaa", marginBottom: 2 }}>
{video.channel}
</div>
<div style={{ fontSize: 13, color: "#aaaaaa" }}>
{formatViewCount(video.viewsNum, viewProgress)} 次觀看 · {video.time}
</div>
</div>
</div>
</div>
);
})}
</AbsoluteFill>
);
};登入後查看完整程式碼