Remotion LabRemotion Lab
返回模板庫

轉換漏斗圖

五階段電商轉換漏斗動畫,梯形色塊由藍漸變至深紫,各階段錯開彈入,左側顯示階段名稱與轉換率,右側顯示數值。

圖表商務簡約
提示詞(可直接修改內容)
import {
  AbsoluteFill,
  interpolate,
  spring,
  useCurrentFrame,
  useVideoConfig,
} from "remotion";
import React from "react";

const STAGES = [
  { label: "曝光", value: 50000, color: "#3b82f6" },
  { label: "點擊", value: 28000, color: "#6366f1" },
  { label: "加入購物車", value: 12000, color: "#8b5cf6" },
  { label: "結帳", value: 5500, color: "#a855f7" },
  { label: "完成購買", value: 3200, color: "#c026d3" },
];

const MAX-VALUE = STAGES[0].value;
const STAGE-HEIGHT = 80;
const MAX-WIDTH = 900;
const MIN-WIDTH = 300;
const CANVAS-WIDTH = 1920;
const CANVAS-HEIGHT = 1080;

function formatValue(v: number): string {
  if (v >= 10000) return (v / 10000).toFixed(1) + " 萬";
  return v.toLocaleString();
}

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

  const titleProgress = spring({
    frame,
    fps,
    config: { damping: 28, stiffness: 80 },
  });
  const titleOpacity = interpolate(titleProgress, [0, 1], [0, 1]);
  const titleY = interpolate(titleProgress, [0, 1], [-30, 0]);

  const totalHeight = STAGES.length * STAGE-HEIGHT + (STAGES.length - 1) * 16;
  const startY = (CANVAS-HEIGHT - totalHeight - 80) / 2 + 60;

  return (
    <AbsoluteFill
      style={{
        background: "#0f0f0f",
        fontFamily: "sans-serif",
        overflow: "hidden",
      }}
    >
      {/* Title */}
      <div
        style={{
          position: "absolute",
          top: 60,
          left: 0,
          right: 0,
          textAlign: "center",
          opacity: titleOpacity,
          transform: `translateY(${titleY}px)`,
        }}
      >
        <div
          style={{
            fontSize: 48,
            fontWeight: 700,
            color: "#ffffff",
            letterSpacing: "0.04em",
          }}
        >
          轉換漏斗分析
        </div>
        <div
          style={{
            marginTop: 8,
            fontSize: 20,
            color: "#6b7280",
            letterSpacing: "0.06em",
          }}
        >
          電商全流程轉換率概覽
        </div>
      </div>

      {/* Funnel stages */}
      {STAGES.map((stage, index) => {
        const startFrame = index * 15 + 10;
        const stageProgress = spring({
          frame: Math.max(0, frame - startFrame),
          fps,
          config: { damping: 22, stiffness: 110 },
        });

        const opacity = interpolate(stageProgress, [0, 0.4], [0, 1], {
          extrapolateRight: "clamp",
        });
        const scale = interpolate(stageProgress, [0, 1], [0.8, 1], {
          extrapolateRight: "clamp",
        });

        const stageWidth =
          MIN-WIDTH +
          ((stage.value / MAX-VALUE) * (MAX-WIDTH - MIN-WIDTH));

        const nextStage = STAGES[index + 1];
        const nextWidth = nextStage
          ? MIN-WIDTH + ((nextStage.value / MAX-VALUE) * (MAX-WIDTH - MIN-WIDTH))
          : stageWidth * 0.85;

        const topInset = (MAX-WIDTH - stageWidth) / 2;
        const bottomInset = (MAX-WIDTH - nextWidth) / 2;

        const yPos = startY + index * (STAGE-HEIGHT + 16);
        const centerX = CANVAS-WIDTH / 2;

        const percentage =
          index === 0
            ? 100
            : Math.round((stage.value / STAGES[index - 1].value) * 100);

        // Conversion rate between stages
        const conversionProgress =
          index < STAGES.length - 1
            ? spring({
                frame: Math.max(0, frame - (startFrame + 15)),
                fps,
                config: { damping: 28, stiffness: 90 },
              })
            : 0;
        const conversionOpacity = interpolate(
          conversionProgress,
          [0, 0.6],
          [0, 1],
          { extrapolateRight: "clamp" }
        );
        const nextConvRate =
          nextStage
            ? Math.round((nextStage.value / stage.value) * 100)
            : null;

        return (
          <React.Fragment key={stage.label}>
            {/* Stage trapezoid + labels */}
            <div
              style={{
                position: "absolute",
                left: centerX - MAX-WIDTH / 2 - 220,
                top: yPos,
                width: MAX-WIDTH + 440,
                height: STAGE-HEIGHT,
                opacity,
                transform: `scale(${scale})`,
                transformOrigin: "center center",
                display: "flex",
                alignItems: "center",
              }}
            >
              {/* Left label */}
              <div
                style={{
                  width: 200,
                  textAlign: "right",
                  paddingRight: 20,
                  flexShrink: 0,
                }}
              >
                <div
                  style={{
                    fontSize: 26,
                    fontWeight: 700,
                    color: "#e5e7eb",
                  }}
                >
                  {stage.label}
                </div>
                {index > 0 && (
                  <div
                    style={{
                      fontSize: 16,
                      color: stage.color,
                      marginTop: 2,
                    }}
                  >
                    {percentage}%
                  </div>
                )}
              </div>

              {/* Trapezoid */}
              <div
                style={{
                  width: MAX-WIDTH,
                  height: STAGE-HEIGHT,
                  clipPath: `polygon(${topInset}px 0%, ${stageWidth + topInset}px 0%, ${nextWidth + bottomInset}px 100%, ${bottomInset}px 100%)`,
                  background: `linear-gradient(135deg, ${stage.color}dd 0%, ${stage.color}88 100%)`,
                  boxShadow: `0 0 30px ${stage.color}44`,
                  flexShrink: 0,
                  position: "relative",
                }}
              >
                {/* Stage label inside trapezoid */}
                <div
                  style={{
                    position: "absolute",
                    inset: 0,
                    display: "flex",
                    alignItems: "center",
                    justifyContent: "center",
                  }}
                >
                  <div
                    style={{
                      fontSize: 20,
                      fontWeight: 600,
                      color: "rgba(255,255,255,0.55)",
                      letterSpacing: "0.06em",
                    }}
                  >
                    {stage.label}
                  </div>
                </div>
              </div>

              {/* Right: value */}
              <div
                style={{
                  width: 220,
                  paddingLeft: 20,
                  flexShrink: 0,
                }}
              >
                <div
                  style={{
                    fontSize: 28,
                    fontWeight: 700,
                    color: "#ffffff",
                  }}
                >
                  {formatValue(stage.value)}
                </div>
              </div>
            </div>

            {/* Conversion rate between stages */}
            {nextConvRate !== null && (
              <div
                style={{
                  position: "absolute",
                  left: centerX + MAX-WIDTH / 2 + 20,
                  top: yPos + STAGE-HEIGHT,
                  height: 16,
                  display: "flex",
                  alignItems: "center",
                  opacity: conversionOpacity,
                }}
              >
                <div
                  style={{
                    fontSize: 17,
                    color: "#9ca3af",
                    fontWeight: 500,
                    whiteSpace: "nowrap",
                  }}
                >
                  {nextConvRate}% →
                </div>
              </div>
            )}
          </React.Fragment>
        );
      })}
    </AbsoluteFill>
  );
};

登入後查看完整程式碼