Skip to content

Commit 5ee4d0d

Browse files
feat: add new Remotion components for follower celebration, hero demo, and message popup
1 parent 66fd4fa commit 5ee4d0d

10 files changed

Lines changed: 815 additions & 7 deletions

File tree

22.4 KB
Loading
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
import {
2+
AbsoluteFill,
3+
Easing,
4+
interpolate,
5+
useCurrentFrame,
6+
useVideoConfig,
7+
CalculateMetadataFunction,
8+
} from "remotion";
9+
10+
export const FOLLOWER_DURATION = 240; // 4s @ 60fps
11+
export const FOLLOWER_FPS = 60;
12+
export const FOLLOWER_WIDTH = 1280;
13+
export const FOLLOWER_HEIGHT = 720;
14+
15+
type Follower = { login: string; avatar_url: string };
16+
17+
export type FollowerProps = {
18+
username: string;
19+
userAvatarUrl: string;
20+
followerCount: number;
21+
followers: Follower[];
22+
};
23+
24+
export const calculateFollowerMetadata: CalculateMetadataFunction<
25+
FollowerProps
26+
> = async ({ props }) => {
27+
const userRes = await fetch(
28+
`https://api.github.com/users/${props.username}`,
29+
);
30+
const user = userRes.ok ? await userRes.json() : null;
31+
32+
const all: Follower[] = [];
33+
const pageRequests = await Promise.all(
34+
[1, 2, 3, 4, 5].map((page) =>
35+
fetch(
36+
`https://api.github.com/users/${props.username}/followers?per_page=100&page=${page}`,
37+
).then((r) => (r.ok ? (r.json() as Promise<Follower[]>) : [])),
38+
),
39+
);
40+
for (const data of pageRequests) {
41+
if (!data.length) break;
42+
all.push(...data);
43+
}
44+
45+
return {
46+
props: {
47+
...props,
48+
userAvatarUrl:
49+
user?.avatar_url ?? `https://github.com/${props.username}.png?size=200`,
50+
followerCount: user?.followers ?? props.followerCount,
51+
followers: all.length ? all : props.followers,
52+
},
53+
};
54+
};
55+
56+
const ANIM_SECONDS = 3;
57+
const AVATAR_SIZE = 128;
58+
const GAP = 16;
59+
const HEART_SIZE = 32;
60+
61+
export const FollowerCelebration: React.FC<FollowerProps> = ({
62+
username,
63+
userAvatarUrl,
64+
followerCount,
65+
followers,
66+
}) => {
67+
return (
68+
<AbsoluteFill style={{ background: "#ffffff" }}>
69+
<Header username={username} userAvatarUrl={userAvatarUrl} />
70+
<FollowersRow followers={followers} />
71+
<Counter targetCount={followerCount} />
72+
</AbsoluteFill>
73+
);
74+
};
75+
76+
function Header({
77+
username,
78+
userAvatarUrl,
79+
}: {
80+
username: string;
81+
userAvatarUrl: string;
82+
}) {
83+
return (
84+
<div
85+
style={{
86+
padding: 64,
87+
fontSize: 72,
88+
fontFamily:
89+
"-apple-system, BlinkMacSystemFont, Inter, 'SF Pro Display', sans-serif",
90+
whiteSpace: "nowrap",
91+
overflow: "hidden",
92+
textOverflow: "ellipsis",
93+
color: "#0f1014",
94+
}}
95+
>
96+
<span>
97+
<img
98+
src={userAvatarUrl}
99+
alt={username}
100+
style={{
101+
display: "inline-block",
102+
width: "1.2em",
103+
height: "1.2em",
104+
borderRadius: "50%",
105+
verticalAlign: "middle",
106+
marginRight: "0.25em",
107+
marginBottom: "0.18em",
108+
objectFit: "cover",
109+
}}
110+
/>
111+
@{username}
112+
</span>
113+
<span style={{ opacity: 0.3, margin: "0 0.25em" }}>·</span>
114+
<strong>followers</strong>
115+
</div>
116+
);
117+
}
118+
119+
function FollowersRow({ followers }: { followers: Follower[] }) {
120+
const frame = useCurrentFrame();
121+
const { fps, width } = useVideoConfig();
122+
if (!followers.length) return null;
123+
124+
const cell = AVATAR_SIZE + GAP;
125+
const total = followers.length;
126+
127+
// Single shared scroll for the whole row.
128+
// Slides from 0 to (total*cell - width*0.75) with elastic ease.
129+
const scroll = interpolate(
130+
frame,
131+
[0, ANIM_SECONDS * fps],
132+
[0, total * cell - width * 0.75],
133+
{ extrapolateRight: "clamp", easing: Easing.elastic(1) },
134+
);
135+
136+
return (
137+
<div style={{ position: "relative", flex: 1 }}>
138+
{followers.map((f, i) => {
139+
const x = GAP + i * cell - scroll;
140+
// Cull avatars outside the viewport
141+
if (x < -AVATAR_SIZE - 100 || x > width + 100) return null;
142+
143+
// Soft fade as they enter from right and exit on left
144+
const enterIn = Math.min(1, (width - x) / 220);
145+
const enterOut = Math.min(1, (x + AVATAR_SIZE) / 220);
146+
const visibility = Math.max(0, Math.min(enterIn, enterOut));
147+
148+
const scale = 0.85 + visibility * 0.15;
149+
const opacity = visibility;
150+
151+
return (
152+
<div
153+
key={i}
154+
style={{
155+
position: "absolute",
156+
top: 0,
157+
left: x,
158+
width: AVATAR_SIZE,
159+
display: "flex",
160+
flexDirection: "column",
161+
alignItems: "center",
162+
opacity,
163+
transform: `scale(${scale})`,
164+
willChange: "transform, opacity",
165+
}}
166+
>
167+
<img
168+
src={f.avatar_url}
169+
alt={f.login}
170+
width={AVATAR_SIZE}
171+
height={AVATAR_SIZE}
172+
style={{
173+
width: AVATAR_SIZE,
174+
height: AVATAR_SIZE,
175+
borderRadius: "50%",
176+
objectFit: "cover",
177+
boxShadow:
178+
"0 2px 4px rgba(15,16,20,0.06), 0 14px 36px rgba(15,16,20,0.16)",
179+
}}
180+
/>
181+
<div style={{ marginTop: 16 }}>
182+
<Heart size={HEART_SIZE} />
183+
</div>
184+
</div>
185+
);
186+
})}
187+
</div>
188+
);
189+
}
190+
191+
function Counter({ targetCount }: { targetCount: number }) {
192+
const frame = useCurrentFrame();
193+
const { fps } = useVideoConfig();
194+
const displayed = Math.round(
195+
interpolate(frame, [0, ANIM_SECONDS * fps], [0, targetCount], {
196+
extrapolateRight: "clamp",
197+
easing: Easing.bezier(0.5, 1, 0.5, 1),
198+
}),
199+
);
200+
201+
return (
202+
<div
203+
style={{
204+
textAlign: "right",
205+
padding: "0 64px 64px",
206+
fontSize: 128,
207+
color: "#0f1014",
208+
fontFamily:
209+
"-apple-system, BlinkMacSystemFont, Inter, 'SF Pro Display', sans-serif",
210+
}}
211+
>
212+
<strong style={{ fontVariantNumeric: "tabular-nums" }}>
213+
{displayed.toLocaleString("en-US")}
214+
</strong>
215+
&nbsp;followers
216+
</div>
217+
);
218+
}
219+
220+
function Heart({ size }: { size: number }) {
221+
return (
222+
<svg
223+
xmlns="http://www.w3.org/2000/svg"
224+
width={size}
225+
height={size}
226+
viewBox="0 0 24 24"
227+
fill="#fb7185"
228+
stroke="#e11d48"
229+
strokeWidth="2"
230+
strokeLinecap="round"
231+
strokeLinejoin="round"
232+
>
233+
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z" />
234+
</svg>
235+
);
236+
}

apps/remotion/src/HeroDemo.tsx

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import {
2+
AbsoluteFill,
3+
interpolate,
4+
spring,
5+
useCurrentFrame,
6+
useVideoConfig,
7+
} from "remotion";
8+
9+
export const HERO_DEMO_DURATION = 150; // 5s @ 30fps
10+
export const HERO_DEMO_FPS = 30;
11+
export const HERO_DEMO_WIDTH = 1280;
12+
export const HERO_DEMO_HEIGHT = 720;
13+
14+
export const HeroDemo: React.FC = () => {
15+
const frame = useCurrentFrame();
16+
const { fps } = useVideoConfig();
17+
18+
const logoScale = spring({
19+
frame,
20+
fps,
21+
from: 0.6,
22+
to: 1,
23+
durationInFrames: 30,
24+
});
25+
const logoOpacity = interpolate(frame, [0, 20], [0, 1], {
26+
extrapolateRight: "clamp",
27+
});
28+
29+
const titleY = interpolate(frame, [25, 50], [30, 0], {
30+
extrapolateLeft: "clamp",
31+
extrapolateRight: "clamp",
32+
});
33+
const titleOpacity = interpolate(frame, [25, 50], [0, 1], {
34+
extrapolateRight: "clamp",
35+
});
36+
37+
const subtitleY = interpolate(frame, [45, 70], [20, 0], {
38+
extrapolateLeft: "clamp",
39+
extrapolateRight: "clamp",
40+
});
41+
const subtitleOpacity = interpolate(frame, [45, 70], [0, 1], {
42+
extrapolateRight: "clamp",
43+
});
44+
45+
const pills = ["Copy & paste", "Fully typed", "Dark mode", "Accessible"];
46+
const pillStart = 80;
47+
48+
return (
49+
<AbsoluteFill
50+
style={{
51+
background:
52+
"radial-gradient(circle at 50% 40%, #1a1a2e 0%, #0a0a0a 60%)",
53+
fontFamily: "Inter, sans-serif",
54+
color: "white",
55+
}}
56+
>
57+
<AbsoluteFill
58+
style={{
59+
backgroundImage:
60+
"linear-gradient(rgba(255,255,255,0.04) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.04) 1px, transparent 1px)",
61+
backgroundSize: "40px 40px",
62+
}}
63+
/>
64+
65+
<AbsoluteFill
66+
style={{ alignItems: "center", justifyContent: "center", gap: 24 }}
67+
>
68+
<div
69+
style={{
70+
opacity: logoOpacity,
71+
transform: `scale(${logoScale})`,
72+
width: 96,
73+
height: 96,
74+
borderRadius: 24,
75+
background: "linear-gradient(135deg, #fff 0%, #888 100%)",
76+
display: "flex",
77+
alignItems: "center",
78+
justifyContent: "center",
79+
fontSize: 56,
80+
fontWeight: 800,
81+
color: "#0a0a0a",
82+
boxShadow: "0 24px 60px rgba(255,255,255,0.15)",
83+
}}
84+
>
85+
a/u
86+
</div>
87+
88+
<div
89+
style={{
90+
opacity: titleOpacity,
91+
transform: `translateY(${titleY}px)`,
92+
fontSize: 80,
93+
fontWeight: 700,
94+
letterSpacing: "-0.04em",
95+
}}
96+
>
97+
aesthetic/ui
98+
</div>
99+
100+
<div
101+
style={{
102+
opacity: subtitleOpacity,
103+
transform: `translateY(${subtitleY}px)`,
104+
fontSize: 26,
105+
color: "rgba(255,255,255,0.6)",
106+
fontWeight: 400,
107+
letterSpacing: "-0.01em",
108+
}}
109+
>
110+
Beautifully designed components. Copy. Paste. Ship.
111+
</div>
112+
113+
<div style={{ display: "flex", gap: 12, marginTop: 24 }}>
114+
{pills.map((label, i) => {
115+
const start = pillStart + i * 8;
116+
const opacity = interpolate(frame, [start, start + 15], [0, 1], {
117+
extrapolateRight: "clamp",
118+
});
119+
const y = interpolate(frame, [start, start + 15], [16, 0], {
120+
extrapolateLeft: "clamp",
121+
extrapolateRight: "clamp",
122+
});
123+
return (
124+
<div
125+
key={label}
126+
style={{
127+
opacity,
128+
transform: `translateY(${y}px)`,
129+
padding: "10px 18px",
130+
borderRadius: 9999,
131+
border: "1px solid rgba(255,255,255,0.15)",
132+
background: "rgba(255,255,255,0.04)",
133+
fontSize: 16,
134+
fontWeight: 500,
135+
color: "rgba(255,255,255,0.85)",
136+
backdropFilter: "blur(6px)",
137+
}}
138+
>
139+
{label}
140+
</div>
141+
);
142+
})}
143+
</div>
144+
</AbsoluteFill>
145+
</AbsoluteFill>
146+
);
147+
};

0 commit comments

Comments
 (0)