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-bandControls
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
- 1Add the VibeBox registry to
components.json:{ "registries": { "@vibebox": "https://motion.vibeboxph.com/r/{name}.json" } } - 2Add the block with the shadcn CLI:
npx shadcn@latest add @vibebox/stats-band - 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 filescomponents/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.
| Slot | Label | Type | Limit | Fact |
|---|---|---|---|---|
| eyebrow | Kicker | text | 32 chars | — |
| heading | Heading | text | 60 chars | — |
| items | Numbers | list | up to 4 | — |
| items[].value | Number | count | — | Yes, a fill-in until given |
| items[].prefix | Before the number | affix | 4 chars | — |
| items[].suffix | After the number | affix | 4 chars | — |
| items[].label | What it counts | text | 48 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.