Remotion LabRemotion Lab
返回模板庫

SVG 漸層邊框卡片:影片學習成果展示

以 SVG 筆畫動畫呈現「看完這支影片獲得 2 個答案」的標題動畫,搭配兩張帶漸層描邊動畫的毛玻璃卡片,以及持續浮動的粒子背景。

SVG漸層卡片粒子描邊動畫
提示詞(可直接修改內容)
import {
  AbsoluteFill,
  Audio,
  Sequence,
  interpolate,
  spring,
  staticFile,
  useCurrentFrame,
  useVideoConfig,
} from "remotion";

const colors = {
  background: "#0B0F17",
  text: "#FFFFFF",
  accent: "#4DA3FF",
  dimmed: "rgba(255, 255, 255, 0.6)",
  cardBg: "rgba(255, 255, 255, 0.05)",
  border: "rgba(77, 163, 255, 0.3)",
};

export const SVG-CARDS-DURATION-FRAMES = 270;

const SFX = {
  woosh: staticFile("audio/connection/woosh.wav"),
  softImpact: staticFile("audio/connection/soft-impact.wav"),
  softClick: staticFile("audio/connection/soft-click.wav"),
};

const SvgTitle: React.FC<{ frame: number; fps: number }> = ({ frame, fps }) => {
  const opacity = interpolate(frame, [0, 20], [0, 1], { extrapolateRight: "clamp", extrapolateLeft: "clamp" });
  const y = interpolate(frame, [0, 25], [30, 0], { extrapolateRight: "clamp", extrapolateLeft: "clamp" });
  const numStrokeLen = 320;
  const numDraw = interpolate(frame, [5, 35], [numStrokeLen, 0], { extrapolateRight: "clamp", extrapolateLeft: "clamp" });
  const numFill = interpolate(frame, [30, 45], [0, 1], { extrapolateRight: "clamp", extrapolateLeft: "clamp" });
  const labelOpacity = interpolate(frame, [25, 40], [0, 1], { extrapolateRight: "clamp", extrapolateLeft: "clamp" });
  const labelX = interpolate(frame, [25, 40], [20, 0], { extrapolateRight: "clamp", extrapolateLeft: "clamp" });
  const subOpacity = interpolate(frame, [0, 18], [0, 0.6], { extrapolateRight: "clamp", extrapolateLeft: "clamp" });
  const lineProgress = interpolate(frame, [35, 55], [0, 1], { extrapolateRight: "clamp", extrapolateLeft: "clamp" });

  return (
    <div style={{ position: "absolute", top: 260, width: "100%", display: "flex", flexDirection: "column", alignItems: "center", opacity, transform: `translateY(${y}px)` }}>
      <div style={{ fontSize: 26, fontWeight: 400, color: colors.text, opacity: subOpacity, letterSpacing: 4, marginBottom: 12 }}>
        看完這支影片你會獲得
      </div>
      <div style={{ display: "flex", alignItems: "baseline", gap: 8 }}>
        <svg width={100} height={100} viewBox="0 0 100 100">
          <defs>
            <linearGradient id="numGrad" x1="0" y1="0" x2="100" y2="100" gradientUnits="userSpaceOnUse">
              <stop offset="0%" stopColor="#4DA3FF" />
              <stop offset="100%" stopColor="#A78BFA" />
            </linearGradient>
          </defs>
          <text x="50" y="82" textAnchor="middle" fontSize="96" fontWeight="800" fontFamily="'Inter', 'Noto Sans TC', sans-serif" fill="none" stroke="url(#numGrad)" strokeWidth={2.5} strokeDasharray={numStrokeLen} strokeDashoffset={numDraw} strokeLinecap="round">2</text>
          <text x="50" y="82" textAnchor="middle" fontSize="96" fontWeight="800" fontFamily="'Inter', 'Noto Sans TC', sans-serif" fill="url(#numGrad)" opacity={numFill}>2</text>
        </svg>
        <span style={{ fontSize: 56, fontWeight: 700, color: colors.text, opacity: labelOpacity, transform: `translateX(${labelX}px)`, display: "inline-block" }}>個答案</span>
      </div>
      <svg width={360} height={8} viewBox="0 0 360 8" style={{ marginTop: 10 }}>
        <defs>
          <linearGradient id="lineGrad" x1="0" y1="0" x2="360" y2="0" gradientUnits="userSpaceOnUse">
            <stop offset="0%" stopColor="#4DA3FF" stopOpacity="0" />
            <stop offset="30%" stopColor="#4DA3FF" />
            <stop offset="70%" stopColor="#A78BFA" />
            <stop offset="100%" stopColor="#A78BFA" stopOpacity="0" />
          </linearGradient>
        </defs>
        <line x1={180 - 180 * lineProgress} y1={4} x2={180 + 180 * lineProgress} y2={4} stroke="url(#lineGrad)" strokeWidth={2} strokeLinecap="round" />
      </svg>
    </div>
  );
};

const cardData = [
  { text: "怎麼用 Claude Code\n+ Remotion 來做動畫", colors: { c1: "#4DA3FF", c2: "#A78BFA" } },
  { text: "怎麼優化動畫的品質\n讓它更有質感", colors: { c1: "#F59E0B", c2: "#EF4444" } },
];

const AnimatedBorder: React.FC<{ width: number; height: number; progress: number; gradientId: string; color1: string; color2: string }> = ({ width, height, progress, gradientId, color1, color2 }) => {
  const rx = 28;
  const perimeter = 2 * (width + height - 4 * rx) + 2 * Math.PI * rx;
  const dashOffset = perimeter * (1 - progress);
  return (
    <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} style={{ position: "absolute", top: 0, left: 0 }}>
      <defs>
        <linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="100%">
          <stop offset="0%" stopColor={color1} />
          <stop offset="100%" stopColor={color2} />
        </linearGradient>
      </defs>
      <rect x={2} y={2} width={width - 4} height={height - 4} rx={rx} ry={rx} fill="none" stroke={`url(#${gradientId})`} strokeWidth={3} strokeDasharray={perimeter} strokeDashoffset={dashOffset} strokeLinecap="round" />
    </svg>
  );
};

const GlowRing: React.FC<{ size: number; color: string; opacity: number; scale: number }> = ({ size, color, opacity, scale }) => (
  <div style={{ position: "absolute", width: size, height: size, borderRadius: "50%", background: `radial-gradient(circle, ${color}40 0%, transparent 70%)`, opacity, transform: `scale(${scale})`, top: "50%", left: "50%", marginTop: -size / 2, marginLeft: -size / 2, pointerEvents: "none" }} />
);

export const SvgCardsScene: React.FC = () => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  const particles = Array.from({ length: 12 }, (_, i) => {
    const baseX = (i * 173) % 1920;
    const baseY = (i * 241) % 1080;
    const speed = 0.3 + (i % 4) * 0.15;
    const y = baseY + Math.sin((frame + i * 30) * speed * 0.03) * 30;
    const x = baseX + Math.cos((frame + i * 50) * speed * 0.02) * 20;
    const size = 2 + (i % 3);
    const opacity = interpolate(Math.sin((frame + i * 40) * 0.04), [-1, 1], [0.05, 0.2]);
    return { x, y, size, opacity };
  });

  const CARD-W = 640;
  const CARD-H = 280;
  const TITLE-DELAY = 5;
  const CARD1-DELAY = 50;
  const CARD2-DELAY = 90;

  return (
    <AbsoluteFill style={{ backgroundColor: colors.background, fontFamily: "'Noto Sans TC', 'Inter', sans-serif" }}>
      <svg width={1920} height={1080} style={{ position: "absolute", top: 0, left: 0 }}>
        {particles.map((p, i) => <circle key={i} cx={p.x} cy={p.y} r={p.size} fill={colors.accent} opacity={p.opacity} />)}
      </svg>
      <Sequence from={TITLE-DELAY}>
        <SvgTitle frame={Math.max(0, frame - TITLE-DELAY)} fps={fps} />
      </Sequence>
      <AbsoluteFill style={{ display: "flex", flexDirection: "row", justifyContent: "center", alignItems: "center", gap: 80, paddingTop: 280 }}>
        {cardData.map((card, index) => {
          const cardDelay = index === 0 ? CARD1-DELAY : CARD2-DELAY;
          const enterScale = spring({ frame: Math.max(0, frame - cardDelay), fps, config: { damping: 14, stiffness: 80 } });
          const enterOpacity = interpolate(frame - cardDelay, [0, 20], [0, 1], { extrapolateRight: "clamp", extrapolateLeft: "clamp" });
          const enterY = interpolate(frame - cardDelay, [0, 30], [60, 0], { extrapolateRight: "clamp", extrapolateLeft: "clamp" });
          const borderProgress = interpolate(frame - cardDelay, [5, 45], [0, 1], { extrapolateRight: "clamp", extrapolateLeft: "clamp" });
          const glowPulse = interpolate(Math.sin((frame - cardDelay) * 0.06), [-1, 1], [0.6, 1]);
          const glowScale = interpolate(Math.sin((frame - cardDelay) * 0.04), [-1, 1], [0.95, 1.1]);
          const textOpacity = interpolate(frame - cardDelay, [15, 35], [0, 1], { extrapolateRight: "clamp", extrapolateLeft: "clamp" });
          return (
            <div key={index} style={{ position: "relative", width: CARD-W, height: CARD-H, opacity: enterOpacity, transform: `scale(${enterScale}) translateY(${enterY}px)` }}>
              <GlowRing size={CARD-W + 100} color={card.colors.c1} opacity={enterOpacity * glowPulse * 0.4} scale={glowScale} />
              <div style={{ position: "relative", width: CARD-W, height: CARD-H, borderRadius: 28, background: "linear-gradient(135deg, rgba(255,255,255,0.06) 0%, rgba(255,255,255,0.02) 100%)", backdropFilter: "blur(8px)", overflow: "hidden", display: "flex", justifyContent: "center", alignItems: "center" }}>
                <AnimatedBorder width={CARD-W} height={CARD-H} progress={borderProgress} gradientId={`border-grad-${index}`} color1={card.colors.c1} color2={card.colors.c2} />
                <div style={{ position: "relative", fontSize: 38, fontWeight: 600, color: colors.text, textAlign: "center", lineHeight: 1.6, whiteSpace: "pre-line", opacity: textOpacity }}>{card.text}</div>
              </div>
            </div>
          );
        })}
      </AbsoluteFill>
      <Sequence from={TITLE-DELAY + 5}><Audio src={SFX.softClick} volume={0.2} /></Sequence>
      <Sequence from={CARD1-DELAY}><Audio src={SFX.woosh} volume={0.25} /></Sequence>
      <Sequence from={CARD1-DELAY + 12}><Audio src={SFX.softImpact} volume={0.18} /></Sequence>
      <Sequence from={CARD2-DELAY}><Audio src={SFX.woosh} volume={0.25} /></Sequence>
      <Sequence from={CARD2-DELAY + 12}><Audio src={SFX.softImpact} volume={0.18} /></Sequence>
    </AbsoluteFill>
  );
};

登入後查看完整程式碼