Skip to content
Motion
FreeIn reviewv1.0.0

Stats Band

Real numbers that roll into place when seen, as an inline band or cards; fill-ins until the client gives them.

npx shadcn@latest add @vibebox/stats-band

Controls

Variant
<StatsBand />

About

Brings Odometer Stat into a section. It is honest by construction: the composer never fills a number, so a fresh site shows designed fill-ins until the client supplies figures they can stand behind.

Use it when

  • The business has two to four real numbers worth stating.
  • Between proof sections (services and testimonials).

Skip it when

  • The numbers are vague or invented: leave the section out.
  • There is only one number: put it in the about story.

Install

  1. 1Add the VibeBox registry to components.json:
    {
      "registries": {
        "@vibebox": "https://motion.vibeboxph.com/r/{name}.json"
      }
    }
  2. 2Add the block with the shadcn CLI:
    npx shadcn@latest add @vibebox/stats-band
  3. 3Use it:
    import { StatsBand } from "@/components/vibebox/stats-band";
    import { home } from "@/content/pages/home";
    
    <StatsBand content={home.stats} variant="inline" id="stats" />

Code

2 files
components/vibebox/stats-band/index.tsx
/**
 * VibeBox Motion · Stats Band
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
import type { CSSProperties } from "react";
import { Container, FillIn, headingId, REVEAL, Section, SectionHead, SlotText, type BlockProps } from "@/lib/vibebox/block-kit";
import { OdometerStat } from "@/components/vibebox/odometer-stat";
import { isFillIn } from "@/lib/vibebox/content/runtime";
import { cn } from "@/lib/utils";
import type { Content, Variant } from "./schema";

/*
 * Real numbers only: each one rolls into place when the band scrolls into view (Odometer Stat)
 * and is plain text for screen readers and search. A number the client has not given is a
 * fill-in, never a guess.
 */

export type StatsBandProps = BlockProps<Content, Variant>;

export function StatsBand({ content, variant = "inline", id, tone, className }: StatsBandProps) {
  const cards = variant === "cards";
  const hasHead = Boolean(content.heading || content.eyebrow);
  return (
    <Section slug="stats-band" variant={variant} id={id} tone={tone} labelledBy={content.heading ? headingId(id) : undefined} spacing={false} className={cn("py-[clamp(48px,7vw,96px)]", className)}>
      <Container className="grid gap-10">
        {hasHead && <SectionHead eyebrow={content.eyebrow} heading={content.heading} id={id} size="h3" />}
        <dl
          className={cn(
            "grid grid-cols-2",
            content.items.length >= 3 && "lg:grid-cols-[repeat(var(--vb-stat-count),minmax(0,1fr))]",
            cards ? "gap-3 sm:gap-4" : "gap-y-8 border-y border-border py-8",
            REVEAL,
          )}
          style={{ "--vb-stat-count": content.items.length } as CSSProperties}
        >
          {content.items.map((item, index) => (
            <div
              key={index}
              className={cn(
                "flex min-w-0 flex-col-reverse justify-end gap-2",
                cards ? "rounded-xl bg-card p-5 ring-1 ring-border sm:p-6" : "px-1 sm:px-6 lg:border-l lg:border-border lg:first:border-l-0 lg:first:pl-0",
              )}
            >
              <SlotText as="dt" value={item.label} path={`items.${index}.label`} className="text-small text-muted-foreground" />
              <dd className="m-0 font-display text-[clamp(2.2rem,1.4rem+2.6vw,3.6rem)] leading-none font-semibold tracking-[-0.03em]">
                {isFillIn(item.value) ? (
                  <FillIn value={item.value} path={`items.${index}.value`} className="text-[0.4em]" />
                ) : (
                  <OdometerStat value={item.value} prefix={item.prefix} suffix={item.suffix} />
                )}
              </dd>
            </div>
          ))}
        </dl>
      </Container>
    </Section>
  );
}
components/vibebox/stats-band/schema.ts
/**
 * VibeBox Motion · Stats Band
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
import { sections, type z } from "@/lib/vibebox/content";

export const variants = ["inline", "cards"] as const;

export type Variant = (typeof variants)[number];

export const content = sections.stats;

export type Content = z.infer<typeof content>;

Content

Typed slots from schema.ts. Facts (hours, prices, quotes, names) come only from the business; until then they render as fill-ins.

SlotLabelTypeLimitFact
eyebrowKickertext32 chars—
headingHeadingtext60 chars—
itemsNumberslistup to 4—
items[].valueNumbercount—Yes, a fill-in until given
items[].prefixBefore the numberaffix4 chars—
items[].suffixAfter the numberaffix4 chars—
items[].labelWhat it countstext48 chars—

Variants

  • Inline banddefault

    inline

    Numbers in a row between hairlines, divided by rules on desktop.

  • Cards

    cards

    Each number on its own card.

Accessibility and motion

Keyboard
Nothing to focus.
Screen readers
A description list: each label with its number as one string.
Touch
Nothing to touch.
Reduced motion
The numbers are shown as they are; nothing rolls.

Edge cases and limits

  • A fill-in replaces the number until the client gives one.
  • Large numbers (₱40,000) group with the locale.
↑ ↓ to move↵ to openesc to close