Skip to content

Components / Data

Bar Grow

A bar chart whose bars grow from the baseline one after another when it scrolls into view; hovering a bar shows its value. Rendered on the server from the data: each bar is a flex child scaled from 0 to 1 on a per-index delay. Composes Reveal; no script of its own. The accessible name summarises the series.

Live demo

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

Uses Reveal and the shared runtime (1.9 kB raw, once per site). With those included: 3.0 kB raw.

CSS 7.9 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · widely available

every browser.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import BarGrow from '@moonarc/core/BarGrow';
---
<BarGrow data={[12, 18, 15, 22, 30, 28, 41]} label="Commits per day" class="text-action" />
<BarGrow data={days.map((d) => ({ label: d.name, value: d.n }))} height="6rem" stagger={40} />
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs Scroll Reveal, the shared runtime and the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/bar-grow.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy Reveal.astro and runtime.ts, and add the JS gate to your head.

BarGrow.astro
---
/**
 * BarGrow — bars grow from the baseline one after another when the chart
 * scrolls into view. Server-rendered from the data: each bar is a flex
 * child whose height is its value and whose scale transitions from 0 to 1
 * on a per-index delay; hovering a bar shows its value. Composes Reveal;
 * zero script of its own. The accessible name summarises the series.
 */
import type { HTMLAttributes, HTMLTag } from 'astro/types';
import Reveal from './Reveal.astro';

interface Bar {
  label?: string;
  value: number;
}

interface Props extends HTMLAttributes<'div'> {
  /** The series: numbers, or { label, value }. */
  data: (number | Bar)[];
  /** Element to render. */
  as?: HTMLTag;
  /** Value that fills the full height. Default: the largest value. */
  max?: number;
  /** Chart height, any CSS length. */
  height?: string;
  /** Bar colour. */
  color?: string;
  /** Grow duration in ms. Unset: the preset's --ma-duration-slow. */
  duration?: number;
  /** Delay between bars in ms. Unset: the preset's --ma-stagger. */
  stagger?: number;
  /** Accessible name; the summary (min, max, last) is appended. */
  label?: string;
  /** Delay in ms before the first bar. */
  delay?: number;
  /** Play once and stay. */
  once?: boolean;
  /** Fraction visible to trigger. */
  threshold?: number;
}

const { data, as = 'div', max, height = '8rem', color = 'var(--chart-1, currentColor)', duration, stagger, label, delay, once, threshold, class: className, style, ...rest } = Astro.props;
const bars: Bar[] = data.map((d) => (typeof d === 'number' ? { value: d } : d));
const values = bars.map((b) => b.value);
const top = max ?? (Math.max(...values, 0) || 1);
// an empty series is named as one: min and max of nothing are Infinity and -Infinity, and its last value is undefined
const name = `${label ? `${label}: ` : ''}${bars.length ? `${bars.length} bars, min ${Math.min(...values)}, max ${Math.max(...values)}, last ${values[values.length - 1]}` : 'no data'}`;
const hasLabels = bars.some((b) => b.label);
// the bars' delay and index are the chart's own (--ma-bars-delay, --ma-bars-i): read as --ma-delay and --ma-i, the delay
// was whatever a Reveal or any other element around the chart had set, and it is no longer the name Reveal writes
const vars = [`--ma-bars-h:${height}`, `--ma-bars-color:${color}`, duration !== undefined && `--ma-bars-dur:${duration}ms`, stagger !== undefined && `--ma-bars-stagger:${stagger}ms`, delay !== undefined && `--ma-bars-delay:${delay}ms`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<Reveal as={as} from="none" delay={delay} once={once} threshold={threshold} class={['ma-bars', className].filter(Boolean).join(' ')} role="img" aria-label={name} style={vars} {...rest}>
  <span class="ma-bars__track" aria-hidden="true">
    {bars.map((b, i) => <span class="ma-bars__bar" data-value={b.value} style={`--ma-bars-i:${i};--ma-bars-v:${Math.max(0, Math.min(1, b.value / top))}`}></span>)}
  </span>
  {hasLabels && <span class="ma-bars__labels" aria-hidden="true">{bars.map((b) => <span class="ma-bars__label">{b.label}</span>)}</span>}
</Reveal>

<style is:global>
  @layer components {
    :where(.ma-bars) {
      display: block;
    }
    :where(.ma-bars__track) {
      display: flex;
      align-items: flex-end;
      gap: 0.3em;
      height: var(--ma-bars-h, 8rem);
    }
    :where(.ma-bars__bar) {
      position: relative;
      flex: 1;
      min-height: 2px;
      height: calc(var(--ma-bars-v, 0) * 100%);
      border-radius: 0.2em 0.2em 0 0;
      background: var(--ma-bars-color, currentColor);
      transform-origin: 50% 100%;
      scale: 1 1;
      transition: scale var(--ma-bars-dur, var(--ma-duration-slow)) var(--ma-ease-out) calc(var(--ma-bars-delay, 0ms) + var(--ma-bars-i, 0) * var(--ma-bars-stagger, var(--ma-stagger)));
    }
    /* the value, on hover */
    :where(.ma-bars__bar)::after {
      content: attr(data-value);
      position: absolute;
      inset: auto 50% 100% auto;
      translate: 50% 0;
      margin-bottom: 0.35em;
      font-size: 0.75em;
      font-variant-numeric: tabular-nums;
      white-space: nowrap;
      opacity: 0;
      transition: opacity var(--ma-duration-fast) var(--ma-ease-out);
    }
    :where(.ma-bars__bar:hover)::after {
      opacity: 1;
    }
    :where(.ma-bars__labels) {
      display: flex;
      gap: 0.3em;
      margin-top: 0.5em;
      font-size: 0.75em;
      opacity: var(--ma-dim, 0.7);
    }
    :where(.ma-bars__label) {
      flex: 1;
      text-align: center;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
    :where([data-ma-js] .ma-bars:not(.ma-in) .ma-bars__bar) {
      scale: 1 0;
      transition: none;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-bars__bar) {
        transition: none;
      }
    }
  }
</style>

Props

PropTypeDefaultDescription
data(number | { label?, value })[]noneThe series; objects add a label under the bar.
asHTMLTag'div'Element to render.
maxnumberlargest valueValue that fills the full height.
heightstring'8rem'Chart height.
colorstring'var(--chart-1, currentColor)'Bar colour: the first chart colour of the theme contract when a theme defines one, else the text colour.
durationnumberpreset (700)Grow duration per bar in ms. Unset, the preset's --ma-duration-slow.
staggernumberpreset (ui: 60)Delay between bars in ms.
labelstringnoneAccessible name; "n bars, min, max, last" is appended.
delaynumber0Delay in ms before the chart fades in and the first bar grows. A delay on a Reveal around the chart does not reach the bars: inside one, give the chart the same delay.
oncebooleantruePlay once and stay.
thresholdnumber0.2Fraction visible to trigger.

Reduced motion

The bars stand at their values at once.

With ClientRouter

Inherits Reveal: rebinds after every navigation.

Why it is built this way

Replaces: bar charts (Animata, Preline) · Chart.js for a seven-bar chart. See the migration table.

Counts a number up from zero to its value when it scrolls into view, with thousands separators, decimals, prefix and suffix.

newly · Chrome 91 · Firefox 128 · Safari 17scroll

Progress

Loading

A bar that fills to its value when it scrolls into view, with the number counting up beside it, or a ring with the number in the middle.

newly · Chrome 91 · Firefox 128 · Safari 17scroll

A small line chart that draws itself from left to right when it scrolls into view; the last point lands and pulses.

every browserscroll

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown