← Catalog
Edit Props (live)
TEXT
TEXT
IMAGE
TEXT
TEXT
TEXT
config
config
config
config
config
config
config
Source
296 linesimport React from "react";
import {
AbsoluteFill,
Img,
useCurrentFrame,
useVideoConfig,
interpolate,
Easing,
spring,
staticFile,
} from "remotion";
import { loadFont as loadInter } from "@remotion/google-fonts/Inter";
loadInter("normal", { weights: ["400", "500", "700", "800", "900"] });
interface TweetCardProps {
authorName: string;
authorHandle: string; // without @
authorAvatar?: string;
verified?: boolean;
bodyText: string;
likes?: number;
retweets?: number;
replies?: number;
views?: number;
bookmarks?: number;
postedAt?: string; // e.g. "4:22 PM · Apr 18, 2025" or "Apr 18"
theme?: "light" | "dark"; // default "light" — real X default on desktop
backgroundColor?: string; // scene bg
}
const FONT = "'Inter', 'Helvetica Neue', -apple-system, system-ui, sans-serif";
const TWITTER_BLUE = "#1d9bf0";
const LIGHT_TEXT = "#0f1419";
const LIGHT_MUTED = "#536471";
const LIGHT_BORDER = "#eff3f4";
const DARK_BG = "#000000";
const DARK_TEXT = "#e7e9ea";
const DARK_MUTED = "#71767b";
const DARK_BORDER = "#2f3336";
const fmt = (n?: number) => {
if (n === undefined || n === null) return "";
if (n >= 1e6) return (n / 1e6).toFixed(n >= 10e6 ? 0 : 1).replace(/\.0$/, "") + "M";
if (n >= 1e3) return (n / 1e3).toFixed(n >= 10e3 ? 0 : 1).replace(/\.0$/, "") + "K";
return String(n);
};
const resolveStatic = (s: string) => (s.startsWith("http") ? s : staticFile(s));
// Official verified-badge path (from X's own SVG spec)
const VERIFIED_PATH =
"M22.25 12c0-1.43-.88-2.67-2.19-3.34.46-1.39.2-2.9-.81-3.91s-2.52-1.27-3.91-.81c-.66-1.31-1.91-2.19-3.34-2.19s-2.67.88-3.33 2.19c-1.4-.46-2.91-.2-3.92.81s-1.26 2.52-.8 3.91c-1.31.67-2.2 1.91-2.2 3.34s.89 2.67 2.2 3.34c-.46 1.39-.21 2.9.8 3.91s2.52 1.26 3.91.81c.67 1.31 1.91 2.19 3.34 2.19s2.68-.88 3.34-2.19c1.39.45 2.9.2 3.91-.81s1.27-2.52.81-3.91c1.31-.67 2.19-1.91 2.19-3.34zm-11.71 4.2L6.8 12.46l1.41-1.42 2.26 2.26 4.8-5.23 1.47 1.36-6.2 6.77z";
export const TweetCard: React.FC<TweetCardProps> = ({
authorName,
authorHandle,
authorAvatar,
verified = false,
bodyText,
likes = 0,
retweets = 0,
replies = 0,
views,
bookmarks,
postedAt = "",
theme = "light",
backgroundColor = "#f2eee4",
}) => {
const frame = useCurrentFrame();
const { fps, width } = useVideoConfig();
const cardBg = theme === "dark" ? DARK_BG : "#ffffff";
const textColor = theme === "dark" ? DARK_TEXT : LIGHT_TEXT;
const mutedColor = theme === "dark" ? DARK_MUTED : LIGHT_MUTED;
const borderColor = theme === "dark" ? DARK_BORDER : LIGHT_BORDER;
// Spring in from below
const cardEnter = spring({ frame, fps, config: { mass: 1, damping: 13, stiffness: 120 } });
const scale = interpolate(cardEnter, [0, 1], [0.88, 1]);
const translateY = interpolate(cardEnter, [0, 1], [100, 0]);
const rot = interpolate(cardEnter, [0, 1], [2, 0]);
// Stats tick up
const counterT = interpolate(frame, [22, 50], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
const shownLikes = Math.floor(likes * counterT);
const shownRts = Math.floor(retweets * counterT);
const shownReplies = Math.floor(replies * counterT);
const shownBookmarks = bookmarks !== undefined ? Math.floor(bookmarks * counterT) : undefined;
const shownViews = views !== undefined ? Math.floor(views * counterT) : undefined;
const t = frame / fps;
const floatY = Math.sin(t * 1.1) * 5;
return (
<AbsoluteFill style={{ backgroundColor, overflow: "hidden" }}>
<div
style={{
position: "absolute",
inset: 0,
background: "transparent",
pointerEvents: "none",
}}
/>
<div
style={{
position: "absolute",
top: "50%",
left: "50%",
width: Math.floor(width * 0.90),
transform: `translate(-50%, calc(-50% + ${translateY + floatY}px)) scale(${scale}) rotate(${rot}deg)`,
background: cardBg,
borderRadius: 24,
padding: "32px 36px 24px",
boxShadow:
theme === "dark"
? "0 50px 100px rgba(0,0,0,0.45), 0 20px 40px rgba(0,0,0,0.25)"
: "0 50px 100px rgba(0,0,0,0.22), 0 20px 40px rgba(0,0,0,0.12), 0 0 0 1px rgba(0,0,0,0.04)",
fontFamily: FONT,
color: textColor,
}}
>
{/* Top row: avatar + name block + more button */}
<div style={{ display: "flex", alignItems: "flex-start", gap: 16 }}>
{authorAvatar ? (
<Img
src={resolveStatic(authorAvatar)}
style={{
width: 88,
height: 88,
borderRadius: "50%",
objectFit: "cover",
flexShrink: 0,
background: theme === "dark" ? "#333" : "#eee",
}}
/>
) : (
<div
style={{
width: 88,
height: 88,
borderRadius: "50%",
background: TWITTER_BLUE,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 40,
fontWeight: 800,
color: "#fff",
flexShrink: 0,
}}
>
{authorName.charAt(0).toUpperCase()}
</div>
)}
<div style={{ display: "flex", flexDirection: "column", gap: 0, minWidth: 0, flex: 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span
style={{
fontSize: 30,
fontWeight: 800,
color: textColor,
letterSpacing: -0.3,
lineHeight: 1.15,
}}
>
{authorName}
</span>
{verified && (
<svg width="28" height="28" viewBox="0 0 24 24" style={{ flexShrink: 0, marginTop: 1 }}>
<path fill={TWITTER_BLUE} d={VERIFIED_PATH} />
</svg>
)}
</div>
<span
style={{
fontSize: 24,
color: mutedColor,
fontWeight: 400,
lineHeight: 1.2,
marginTop: 2,
}}
>
@{authorHandle}
</span>
</div>
{/* More (3-dots) */}
<svg width="30" height="30" viewBox="0 0 24 24" style={{ flexShrink: 0, marginTop: 4 }}>
<circle cx="5" cy="12" r="2" fill={mutedColor} />
<circle cx="12" cy="12" r="2" fill={mutedColor} />
<circle cx="19" cy="12" r="2" fill={mutedColor} />
</svg>
</div>
{/* Body */}
<div
style={{
marginTop: 18,
fontSize: 40,
lineHeight: 1.34,
fontWeight: 400,
letterSpacing: -0.3,
color: textColor,
whiteSpace: "pre-wrap",
}}
>
{bodyText}
</div>
{/* Timestamp + views row (real X shows this below body for detail view) */}
{(postedAt || shownViews !== undefined) && (
<div
style={{
marginTop: 18,
fontSize: 22,
color: mutedColor,
fontWeight: 400,
display: "flex",
alignItems: "center",
gap: 6,
}}
>
{postedAt && <span>{postedAt}</span>}
{postedAt && shownViews !== undefined && <span>·</span>}
{shownViews !== undefined && (
<>
<span style={{ color: textColor, fontWeight: 700 }}>{fmt(shownViews)}</span>
<span>Views</span>
</>
)}
</div>
)}
{/* Divider */}
<div style={{ height: 1, background: borderColor, marginTop: 16 }} />
{/* Engagement row */}
<div
style={{
marginTop: 14,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
color: mutedColor,
fontSize: 22,
fontWeight: 500,
}}
>
<Stat icon="reply" value={shownReplies} color={mutedColor} />
<Stat icon="retweet" value={shownRts} color={mutedColor} />
<Stat icon="like" value={shownLikes} color={mutedColor} />
{shownBookmarks !== undefined && (
<Stat icon="bookmark" value={shownBookmarks} color={mutedColor} />
)}
<Stat icon="share" value={null} color={mutedColor} />
</div>
</div>
</AbsoluteFill>
);
};
const ICON_PATHS: Record<string, string> = {
reply:
"M1.751 10c0-4.42 3.584-8 8.005-8h4.366c4.49 0 8.129 3.64 8.129 8.13 0 2.96-1.607 5.68-4.196 7.11l-8.054 4.46v-3.69h-.067c-4.49.1-8.183-3.51-8.183-8.01zm8.005-6c-3.317 0-6.005 2.69-6.005 6 0 3.37 2.77 6.08 6.138 6.01l.351-.01h1.761v2.3l5.087-2.81c1.951-1.08 3.163-3.13 3.163-5.36 0-3.39-2.744-6.13-6.129-6.13H9.756z",
retweet:
"M4.5 3.88l4.432 4.14-1.364 1.46L5.5 7.55V16c0 1.1.896 2 2 2H13v2H7.5c-2.209 0-4-1.79-4-4V7.55L1.432 9.48.068 8.02 4.5 3.88zM16.5 6H11V4h5.5c2.209 0 4 1.79 4 4v8.45l2.068-1.93 1.364 1.46-4.432 4.14-4.432-4.14 1.364-1.46 2.068 1.93V8c0-1.1-.896-2-2-2z",
like:
"M16.697 5.5c-1.222-.06-2.679.51-3.89 2.16l-.805 1.09-.806-1.09C9.984 6.01 8.526 5.44 7.304 5.5c-1.243.07-2.349.78-2.91 1.91-.552 1.12-.633 2.78.479 4.82 1.074 1.97 3.257 4.27 7.129 6.61 3.87-2.34 6.052-4.64 7.126-6.61 1.111-2.04 1.03-3.7.477-4.82-.561-1.13-1.666-1.84-2.908-1.91zm4.187 7.69c-1.351 2.48-4.001 5.12-8.379 7.67l-.503.3-.504-.3c-4.379-2.55-7.029-5.19-8.382-7.67-1.36-2.5-1.41-4.86-.514-6.67.887-1.79 2.647-2.91 4.601-3.01 1.651-.09 3.368.56 4.798 2.01 1.429-1.45 3.146-2.1 4.796-2.01 1.954.1 3.714 1.22 4.601 3.01.896 1.81.846 4.17-.514 6.67z",
bookmark:
"M4 4.5C4 3.12 5.119 2 6.5 2h11C18.881 2 20 3.12 20 4.5v18.44l-8-5.71-8 5.71V4.5zM6.5 4c-.276 0-.5.22-.5.5v14.56l6-4.29 6 4.29V4.5c0-.28-.224-.5-.5-.5h-11z",
share:
"M12 2.59l5.7 5.7-1.41 1.42L13 6.41V16h-2V6.41l-3.3 3.3-1.41-1.42L12 2.59zM21 15l-.02 3.51c0 1.38-1.12 2.49-2.5 2.49H5.5C4.11 21 3 19.88 3 18.5V15h2v3.5c0 .28.22.5.5.5h12.98c.28 0 .5-.22.5-.5L19 15h2z",
};
const Stat: React.FC<{ icon: keyof typeof ICON_PATHS; value: number | null; color: string }> = ({
icon,
value,
color,
}) => {
return (
<div style={{ display: "flex", alignItems: "center", gap: 8, color }}>
<svg width="30" height="30" viewBox="0 0 24 24">
<path fill={color} d={ICON_PATHS[icon]} />
</svg>
{value !== null && <span>{fmt(value)}</span>}
</div>
);
};
Schema (Zod)
// Auto-extracted from Props interface — see source for authoritative types.
// Component: TweetCard
// 13 props detected · 6 port(s)
Tweet Card
tweet_cardTweet Card — Andy's hand-crafted Remotion scene. 13 props. authorName:TEXT, authorHandle:TEXT, authorAvatar:IMAGE, +3 more.
Workflow Inputs (6)
- Author Name
authorNameTEXTRequired - Author Handle
authorHandleTEXTRequired - Author Avatar
authorAvatarIMAGE - Body Text
bodyTextTEXTRequired - Retweets
retweetsTEXT - Posted At
postedAtTEXT
Config Fields (7)
likesthemeviewsrepliesverifiedbookmarksbackgroundColor
Meta
- Updated
- 4/21/2026