# Bar Grow (Moonarc)

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.

- Import: `import BarGrow from '@moonarc/core/BarGrow'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/bar-grow.json`
- Tier A · category data · trigger scroll
- Readout: `<BarGrow data={[…]}>`
- Browser support: widely (every browser)
- Measured cost: 0 B JS of its own · uses Reveal + runtime (with dependencies 3.0 kB raw; CSS 7.9 kB raw)
- Page: https://moonarc.dev/components/bar-grow/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `data` | `(number | { label?, value })[]` | none | The series; objects add a label under the bar. |
| `as` | `HTMLTag` | `'div'` | Element to render. |
| `max` | `number` | `largest value` | Value that fills the full height. |
| `height` | `string` | `'8rem'` | Chart height. |
| `color` | `string` | `'var(--chart-1, currentColor)'` | Bar colour: the first chart colour of the theme contract when a theme defines one, else the text colour. |
| `duration` | `number` | `preset (700)` | Grow duration per bar in ms. Unset, the preset's --ma-duration-slow. |
| `stagger` | `number` | `preset (ui: 60)` | Delay between bars in ms. |
| `label` | `string` | none | Accessible name; "n bars, min, max, last" is appended. |
| `delay` | `number` | `0` | Delay 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. |
| `once` | `boolean` | `true` | Play once and stay. |
| `threshold` | `number` | `0.2` | Fraction visible to trigger. |

## Usage

```astro
<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} />
```

## Reduced motion

The bars stand at their values at once.

## With ClientRouter

Inherits Reveal: rebinds after every navigation.

## Craft

- scale on a bottom origin, not height: one compositor property, no layout per frame, and the rounded top stays rounded.
- Left to right on the preset stagger: a chart is read in order, and a stagger that runs with the reading direction is the chart being drawn.
- Ease-out at the slow duration: bars that shoot up and settle read as data arriving; bars that ease in read as loading.
- The value is a data attribute painted by ::after on hover, with no tooltip element per bar and no script.
- role="img" with a summary of the series in the name; the bars are aria-hidden decoration on top of that sentence.

## Replaces

- bar charts (Animata, Preline)
- Chart.js for a seven-bar chart

## Source

```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>

```
