{
  "name": "animated-bar-chart",
  "type": "registry:block",
  "description": "Ranked bar chart scene with a value axis",
  "dependencies": [
    "remotion",
    "@remotion/google-fonts"
  ],
  "registryDependencies": [
    "chart-utils",
    "layout",
    "motion-tokens"
  ],
  "files": [
    {
      "path": "registry/bases/default/scenes/animated-bar-chart/index.tsx",
      "type": "registry:block",
      "content": "import { loadFont } from \"@remotion/google-fonts/Inter\";\nimport { interpolate, spring, useCurrentFrame, useVideoConfig } from \"remotion\";\nimport {\n  formatAxisValue,\n  formatCompactNumber,\n  niceDomain,\n  readDelta,\n  type ChartDatum,\n} from \"@/remotion/lib/chart-utils\";\nimport { getSafeAreaPadding, scaleFont } from \"@/remotion/lib/layout\";\nimport { DURATION, EASING, STAGGER } from \"@/remotion/lib/motion-tokens\";\n\nconst { fontFamily } = loadFont(\"normal\", {\n  weights: [\"400\", \"500\", \"600\", \"700\"],\n  subsets: [\"latin\"],\n});\n\nexport type AnimatedBarChartProps = {\n  data: ChartDatum[];\n  title?: string;\n  /** Supporting line under the title — the read, not a repeat of the title. */\n  subtitle?: string;\n  /** Fixed axis top. Defaults to a rounded domain above the largest bar. */\n  maxValue?: number;\n  /** Formats the value on the end of each bar. */\n  valueFormatter?: (value: number) => string;\n  /** Label of the bar that carries `accentColor`. */\n  highlightLabel?: string;\n  /** Vertical gridlines and the value axis under the bars. */\n  showAxis?: boolean;\n  /** Bars beyond this count are dropped rather than squeezed. */\n  maxBars?: number;\n  barColor?: string;\n  accentColor?: string;\n  backgroundColor?: string;\n};\n\nconst COLORS = {\n  bg: \"#080810\",\n  label: \"rgba(250,250,250,0.62)\",\n  axis: \"rgba(250,250,250,0.42)\",\n  grid: \"rgba(250,250,250,0.08)\",\n  track: \"rgba(250,250,250,0.05)\",\n  ink: \"#fafafa\",\n  bar: \"#2dd4bf\",\n  accent: \"#e8b86d\",\n  up: \"#2dd4bf\",\n  down: \"#f87171\",\n} as const;\n\n/**\n * Ranked bar chart scene.\n *\n * Bars are measured against a rounded axis rather than the largest value, so\n * the longest bar stops short of the frame edge and the numbers under it read\n * as a scale instead of decoration. Each bar's length and its counter share one\n * spring — the number can never claim a total the bar has not reached yet.\n */\nexport const AnimatedBarChart: React.FC<AnimatedBarChartProps> = ({\n  data,\n  title,\n  subtitle,\n  maxValue,\n  valueFormatter = formatCompactNumber,\n  highlightLabel,\n  showAxis = true,\n  maxBars = 6,\n  barColor = COLORS.bar,\n  accentColor = COLORS.accent,\n  backgroundColor = COLORS.bg,\n}) => {\n  const frame = useCurrentFrame();\n  const { fps, width, height } = useVideoConfig();\n  const safe = getSafeAreaPadding({ width, height });\n  const isPortrait = height > width;\n\n  const bars = data.slice(0, maxBars);\n  const domain = niceDomain(\n    maxValue === undefined ? bars.map((item) => item.value) : [0, maxValue],\n    // Fewer ticks in portrait, but not so few that the axis rounds up to\n    // double the largest bar and throws away half the width.\n    { includeZero: true, tickCount: isPortrait ? 3 : 4 },\n  );\n\n  const gap = scaleFont(16, width);\n  const longestLabel = bars.reduce(\n    (longest, item) => Math.max(longest, item.label.length),\n    0,\n  );\n  const labelSize = scaleFont(isPortrait ? 30 : 28, width);\n  const valueSize = scaleFont(isPortrait ? 32 : 30, width);\n  // Gutters are sized from the actual copy so nothing truncates and no bar\n  // column is thrown away on padding that holds two characters.\n  const labelWidth = Math.min(\n    Math.round(width * 0.26),\n    Math.round(Math.min(longestLabel, 14) * labelSize * 0.62) + gap,\n  );\n  const valueWidth = Math.round(scaleFont(96, width));\n  const barHeight = Math.min(\n    scaleFont(52, width),\n    Math.round(\n      (height * (isPortrait ? 0.44 : 0.52)) / Math.max(1, bars.length),\n    ),\n  );\n\n  const headerProgress = interpolate(frame, [0, DURATION.normal], [0, 1], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n    easing: EASING.enter,\n  });\n  const subtitleProgress = interpolate(\n    frame,\n    [STAGGER.tight, STAGGER.tight + DURATION.normal],\n    [0, 1],\n    {\n      extrapolateLeft: \"clamp\",\n      extrapolateRight: \"clamp\",\n      easing: EASING.enter,\n    },\n  );\n  const axisProgress = interpolate(\n    frame,\n    [STAGGER.normal, STAGGER.normal + DURATION.normal],\n    [0, 1],\n    {\n      extrapolateLeft: \"clamp\",\n      extrapolateRight: \"clamp\",\n      easing: EASING.enter,\n    },\n  );\n\n  return (\n    <div\n      style={{\n        width,\n        height,\n        background: backgroundColor,\n        color: COLORS.ink,\n        fontFamily,\n        paddingLeft: safe.paddingLeft,\n        paddingRight: safe.paddingRight,\n        paddingTop: safe.paddingTop,\n        paddingBottom: safe.paddingBottom,\n        display: \"flex\",\n        flexDirection: \"column\",\n        // A tall frame has far more room than four rows need, so the whole\n        // stack centres together instead of stranding the title at the top.\n        justifyContent: isPortrait ? \"center\" : \"flex-start\",\n        gap: scaleFont(40, width),\n      }}\n    >\n      {title ? (\n        <header style={{ display: \"grid\", gap: scaleFont(12, width) }}>\n          <h2\n            style={{\n              margin: 0,\n              fontSize: scaleFont(isPortrait ? 76 : 64, width),\n              fontWeight: 700,\n              lineHeight: 1.02,\n              letterSpacing: \"-0.025em\",\n              opacity: headerProgress,\n              transform: `translateY(${(1 - headerProgress) * scaleFont(18, width)}px)`,\n            }}\n          >\n            {title}\n          </h2>\n          {subtitle ? (\n            <p\n              style={{\n                margin: 0,\n                color: COLORS.label,\n                fontSize: scaleFont(30, width),\n                fontWeight: 500,\n                lineHeight: 1.25,\n                opacity: subtitleProgress,\n                transform: `translateY(${(1 - subtitleProgress) * scaleFont(12, width)}px)`,\n              }}\n            >\n              {subtitle}\n            </p>\n          ) : null}\n        </header>\n      ) : null}\n\n      <div\n        style={{\n          flex: isPortrait ? \"0 0 auto\" : 1,\n          display: \"flex\",\n          flexDirection: \"column\",\n          justifyContent: \"center\",\n        }}\n      >\n        {/* Gridlines are scoped to the rows so they never run on through the\n            empty space a tall frame leaves above and below the chart. */}\n        <div\n          style={{\n            position: \"relative\",\n            display: \"flex\",\n            flexDirection: \"column\",\n            gap: scaleFont(18, width),\n          }}\n        >\n          {showAxis ? (\n            <div\n              style={{\n                position: \"absolute\",\n                left: labelWidth + gap,\n                right: valueWidth + gap,\n                top: 0,\n                bottom: 0,\n                opacity: axisProgress,\n              }}\n            >\n              {domain.ticks.map((tick) => (\n                <div\n                  key={tick}\n                  style={{\n                    position: \"absolute\",\n                    top: 0,\n                    bottom: 0,\n                    left: `${((tick - domain.min) / domain.span) * 100}%`,\n                    width: 1,\n                    background: COLORS.grid,\n                  }}\n                />\n              ))}\n            </div>\n          ) : null}\n\n          {bars.map((item, index) => {\n            const delay = STAGGER.normal + index * STAGGER.normal;\n            // One spring drives length, counter and row lift together.\n            const enter = spring({\n              frame: frame - delay,\n              fps,\n              config: { damping: 20, stiffness: 110, mass: 0.9 },\n              durationInFrames: DURATION.slow,\n            });\n            const isHighlighted = highlightLabel === item.label;\n            const fill = item.color ?? (isHighlighted ? accentColor : barColor);\n            const ratio = Math.max(0, (item.value - domain.min) / domain.span);\n            const delta = readDelta(item.delta);\n\n            return (\n              <div\n                key={item.label}\n                style={{\n                  display: \"grid\",\n                  gridTemplateColumns: `${labelWidth}px 1fr ${valueWidth}px`,\n                  gap,\n                  alignItems: \"center\",\n                  opacity: Math.min(1, enter * 1.6),\n                  transform: `translateY(${(1 - enter) * scaleFont(14, width)}px)`,\n                }}\n              >\n                <div\n                  style={{\n                    color: isHighlighted ? COLORS.ink : COLORS.label,\n                    fontSize: labelSize,\n                    fontWeight: isHighlighted ? 700 : 600,\n                    letterSpacing: \"-0.01em\",\n                    overflow: \"hidden\",\n                    textOverflow: \"ellipsis\",\n                    whiteSpace: \"nowrap\",\n                  }}\n                >\n                  {item.label}\n                </div>\n\n                <div\n                  style={{\n                    position: \"relative\",\n                    height: barHeight,\n                    borderRadius: barHeight / 2,\n                    background: COLORS.track,\n                  }}\n                >\n                  <div\n                    style={{\n                      // A floor keeps a near-zero bar visible as a pill, but it\n                      // still grows from nothing rather than popping in at width.\n                      width: `${Math.max(ratio, 0.015) * enter * 100}%`,\n                      height: \"100%\",\n                      borderRadius: barHeight / 2,\n                      background: `linear-gradient(90deg, ${fill}e6 0%, ${fill} 62%)`,\n                      boxShadow: isHighlighted\n                        ? `0 0 ${scaleFont(28, width)}px ${fill}44`\n                        : undefined,\n                    }}\n                  />\n                </div>\n\n                <div\n                  style={{\n                    display: \"grid\",\n                    justifyItems: \"end\",\n                    gap: scaleFont(4, width),\n                  }}\n                >\n                  <div\n                    style={{\n                      fontSize: valueSize,\n                      fontWeight: 700,\n                      letterSpacing: \"-0.02em\",\n                      lineHeight: 1,\n                      fontVariantNumeric: \"tabular-nums\",\n                      color: isHighlighted ? fill : COLORS.ink,\n                    }}\n                  >\n                    {valueFormatter(item.value * enter)}\n                  </div>\n                  {delta.text ? (\n                    <div\n                      style={{\n                        fontSize: scaleFont(21, width),\n                        fontWeight: 600,\n                        lineHeight: 1,\n                        color:\n                          delta.direction === \"down\"\n                            ? COLORS.down\n                            : delta.direction === \"up\"\n                              ? COLORS.up\n                              : COLORS.label,\n                        opacity: interpolate(enter, [0.7, 1], [0, 1], {\n                          extrapolateLeft: \"clamp\",\n                          extrapolateRight: \"clamp\",\n                        }),\n                      }}\n                    >\n                      {delta.text}\n                    </div>\n                  ) : null}\n                </div>\n              </div>\n            );\n          })}\n        </div>\n\n        {showAxis ? (\n          <div\n            style={{\n              position: \"relative\",\n              height: scaleFont(24, width),\n              marginLeft: labelWidth + gap,\n              marginRight: valueWidth + gap,\n              marginTop: scaleFont(10, width),\n              opacity: axisProgress,\n            }}\n          >\n            {domain.ticks.map((tick, index) => (\n              <div\n                key={tick}\n                style={{\n                  position: \"absolute\",\n                  top: 0,\n                  left: `${((tick - domain.min) / domain.span) * 100}%`,\n                  // End ticks pull inward so the axis never overhangs the plot.\n                  transform:\n                    index === 0\n                      ? \"none\"\n                      : index === domain.ticks.length - 1\n                        ? \"translateX(-100%)\"\n                        : \"translateX(-50%)\",\n                  color: COLORS.axis,\n                  fontSize: scaleFont(21, width),\n                  fontWeight: 600,\n                  fontVariantNumeric: \"tabular-nums\",\n                  whiteSpace: \"nowrap\",\n                }}\n              >\n                {formatAxisValue(tick)}\n              </div>\n            ))}\n          </div>\n        ) : null}\n      </div>\n    </div>\n  );\n};\n"
    }
  ],
  "atlas": {
    "lane": "signals",
    "drive": "data",
    "tier": "advanced",
    "tags": [
      "charts"
    ]
  }
}