Count Up
TextCounts a number up from zero to its value when it scrolls into view, with thousands separators, decimals, prefix and suffix.
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, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
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
every browser.
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
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} />Owns the file, no dependency. The registry item also installs Scroll Reveal, the shared runtime and the base tokens.
npx shadcn@latest add https://moonarc.dev/r/bar-grow.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
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 — 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>| 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. |
The bars stand at their values at once.
ClientRouterInherits Reveal: rebinds after every navigation.
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.
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.
A small line chart that draws itself from left to right when it scrolls into view; the last point lands and pulses.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown