Catalog

Edit Props (live)

TEXT
TEXT
IMAGE
TEXT
TEXT
TEXT
config
config
config
config
config
config
config

Source

296 lines
import 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_card

Tweet Card — Andy's hand-crafted Remotion scene. 13 props. authorName:TEXT, authorHandle:TEXT, authorAvatar:IMAGE, +3 more.

Workflow Inputs (6)

  • Author Name
    authorName
    TEXTRequired
  • Author Handle
    authorHandle
    TEXTRequired
  • Author Avatar
    authorAvatar
    IMAGE
  • Body Text
    bodyText
    TEXTRequired
  • Retweets
    retweets
    TEXT
  • Posted At
    postedAt
    TEXT

Config Fields (7)

  • likes
  • theme
  • views
  • replies
  • verified
  • bookmarks
  • backgroundColor

Meta

Updated
4/21/2026