Count Up
TextCounts a number up from zero to its value when it scrolls into view, with thousands separators, decimals, prefix and suffix.
A departures board: every character spins through the alphabet and locks, one cell after another, when the board scrolls into view. Each glyph is a CSS counter on a registered integer that transitions from blank to the letter, and a flap turns once per step. Composes Reveal; no script of its own. The server HTML already reads the final text.
DEPARTURES
AMS 14:05 ON TIME
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 8.8 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.
Browser support
Chrome 91 · Firefox 128 · Safari 17.2; needs counter-set, property, counter-style. Elsewhere: the final text, shown at once.
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 SplitFlap from '@moonarc/core/SplitFlap';
---
<SplitFlap as="h2" text="Departures" class="font-mono" />
<SplitFlap text="AMS 14:05 ON TIME" step={45} stagger={30} />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/split-flap.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.
---
/**
* SplitFlap — a departures board. Every character is a cell whose letter is
* a CSS counter on a registered integer: the integer transitions from blank
* to the target letter's index in the charset, so the glyph is rewritten one
* step at a time until it locks, and a flap on the cell turns once per step.
* Composes Reveal for the scroll trigger; zero script of its own. The server
* HTML carries the final text (.ma-sr + counters at their targets).
*/
import type { HTMLAttributes, HTMLTag } from 'astro/types';
import Reveal from './Reveal.astro';
interface Props extends HTMLAttributes<'span'> {
/** The text. Boards are uppercase: letters are uppercased, characters outside the set render as ?. */
text: string;
/** Element to render. */
as?: HTMLTag;
/** Milliseconds per character step (one flap). Unset: the preset's --ma-stagger. */
step?: number;
/** Milliseconds between neighbouring cells starting. Unset: the preset's --ma-stagger-tight. */
stagger?: number;
/** Delay in ms before the first cell. */
delay?: number;
/** Play once and stay. */
once?: boolean;
/** Fraction visible to trigger. */
threshold?: number;
}
const { text, as = 'span', step, stagger, delay, once, threshold, class: className, style, ...rest } = Astro.props;
// Index 0 is blank; the @counter-style below lists the same symbols in the same order.
const SET = ' ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789:.-/+&?!';
// one cell per grapheme, not per code point: an emoji with a skin tone is one ? on the board, not two
const cells = Array.from(new Intl.Segmenter().segment(text), (g) => g.segment).map((c) => {
const i = SET.indexOf(c.toUpperCase());
return { c, to: c === ' ' ? -1 : i >= 0 ? i : SET.indexOf('?') };
});
const vars = [step !== undefined && `--ma-flap-step:${step}ms`, stagger !== undefined && `--ma-flap-stagger:${stagger}ms`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
// dir="auto": the board is a flex row, which follows the page's direction, so on a right-to-left page it spelled its
// text back to front. The board takes its direction from its own text instead.
---
<Reveal as={as} from="none" delay={delay} once={once} threshold={threshold} class={['ma-flap', className].filter(Boolean).join(' ')} dir="auto" style={vars || undefined} {...rest}>
{cells.map(({ to }, i) => (to < 0 ? <span class="ma-flap__gap" aria-hidden="true"></span> : <span class="ma-flap__cell" aria-hidden="true" style={`--ma-flap-to:${to};--ma-i:${i}`}><b class="ma-flap__top"></b><b class="ma-flap__bot"></b><b class="ma-flap__flap"></b></span>))}
<span class="ma-sr">{text}</span>
</Reveal>
<style is:global>
@property --ma-flap-n {
syntax: '<integer>';
inherits: false;
initial-value: 0;
}
@layer components {
/* the charset, in the same order as the server's SET — value 0 is the blank */
@counter-style ma-flap-set {
system: fixed 0;
symbols: ' ' 'A' 'B' 'C' 'D' 'E' 'F' 'G' 'H' 'I' 'J' 'K' 'L' 'M' 'N' 'O' 'P' 'Q' 'R' 'S' 'T' 'U' 'V' 'W' 'X' 'Y' 'Z' '0' '1' '2' '3' '4' '5' '6' '7' '8' '9' ':' '.' '-' '/' '+' '&' '?' '!';
}
:where(.ma-flap) {
display: inline-flex;
flex-wrap: wrap;
gap: 0.08em;
line-height: 1;
font-variant-numeric: tabular-nums;
}
:where(.ma-flap__gap) {
width: 0.5em;
}
:where(.ma-flap__cell) {
position: relative;
display: inline-grid;
min-width: 1em;
height: 1.3em;
padding-inline: 0.1em;
perspective: 6em;
--ma-flap-n: var(--ma-flap-to, 0);
/* counter-set, not counter-reset: WebKit paints a stale glyph for siblings when a counter-reset value changes through a transition (found in 7B) */
counter-set: ma-flap var(--ma-flap-n);
/* linear on purpose: an integer ramp, so every character step takes the same time */
transition: --ma-flap-n calc(var(--ma-flap-to, 0) * var(--ma-flap-step, var(--ma-stagger))) linear calc(var(--ma-r-delay, 0ms) + var(--ma-i, 0) * var(--ma-flap-stagger, var(--ma-stagger-tight)));
}
/* the hinge */
:where(.ma-flap__cell)::after {
content: '';
position: absolute;
inset: 50% 0 auto 0;
height: 1px;
background: var(--ma-glow);
pointer-events: none;
}
:where(.ma-flap__top),
:where(.ma-flap__bot),
:where(.ma-flap__flap) {
grid-area: 1 / 1;
display: grid;
place-items: center;
font-weight: inherit;
border-radius: 0.12em;
background: var(--ma-edge);
}
:where(.ma-flap__top)::before,
:where(.ma-flap__bot)::before,
:where(.ma-flap__flap)::before {
content: counter(ma-flap, ma-flap-set);
white-space: pre;
}
:where(.ma-flap__top),
:where(.ma-flap__flap) {
clip-path: inset(0 0 50% 0);
}
:where(.ma-flap__bot) {
clip-path: inset(50% 0 0 0);
}
:where(.ma-flap__flap) {
transform-origin: 50% 50%;
backface-visibility: hidden;
}
/* Under the JS gate the cells start blank and step to their letter once Reveal marks the board in view; the flap turns once per step. */
:where([data-ma-js] .ma-flap:not(.ma-in) .ma-flap__cell) {
--ma-flap-n: 0;
transition: none;
}
:where(.ma-flap.ma-in .ma-flap__flap) {
animation: ma-flap-turn var(--ma-flap-step, var(--ma-stagger)) var(--ma-ease-in) calc(var(--ma-r-delay, 0ms) + var(--ma-i, 0) * var(--ma-flap-stagger, var(--ma-stagger-tight))) var(--ma-flap-to, 0);
}
@media (prefers-reduced-motion: reduce) {
:where(.ma-flap__cell) {
transition: none;
}
:where(.ma-flap.ma-in .ma-flap__flap) {
animation: none;
}
}
/* Print: every cell at its letter. A board printed before it scrolled into view was a row of blank cells. */
@media print {
:where(.ma-flap .ma-flap__cell) {
--ma-flap-n: var(--ma-flap-to, 0);
transition: none;
}
:where(.ma-flap .ma-flap__flap) {
animation: none;
}
}
}
@keyframes ma-flap-turn {
from {
transform: rotateX(0);
}
to {
transform: rotateX(-90deg);
}
}
</style>| Prop | Type | Default | Description |
|---|---|---|---|
text | string | none | The text. Boards are uppercase, so letters are uppercased; A–Z, 0–9 and : . - / + & ? ! are in the set, anything else renders as ?. |
as | HTMLTag | 'span' | Element to render. |
step | number | preset (ui: 60) | Milliseconds per character step (one flap). Unset, it follows the preset's --ma-stagger; 45–70 reads like a real board. |
stagger | number | preset (40) | Milliseconds between neighbouring cells starting. Unset, the preset's --ma-stagger-tight. |
delay | number | 0 | Delay in ms before the first cell. |
once | boolean | true | Play once and stay; false replays on every entry. |
threshold | number | 0.2 | Fraction visible to trigger. |
The final text appears at once; nothing spins.
ClientRouterInherits Reveal: rebinds after every navigation, so the board replays on a fresh page.
Replaces: SplitFlap / FlipText effects (React Bits, Aceternity) · split-flap canvas boards. 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.
Random characters resolve into the real text from left to right when it scrolls into view, on load, or on hover.
Types a string one character at a time in any font, with a caret that stays solid while typing and blinks once idle.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown