Cards✨ Premium

Bento with Stats

Bento stats grid: counters animate on scroll via IntersectionObserver, progress bars reveal on hover with a CSS transition.

JSStatsCounter

Updated

This effect is part of Effect.Labs — 840 vanilla effects, some free, some premium. Cards has 43 effects, including 6 free. Explore the category →

3 usage examples

Analytics dashboard — Key metrics at a glance — Bento with Stats example 1

① Analytics dashboard — Key metrics at a glance

WhenThe home page of an analytics, BI, or monitoring tool opens and presents the main KPIs in a compact bento grid.
WhyCounter animation on scroll draws attention exactly when the user reaches the data zone — more impactful than a static list of figures.
SettingsTargets: visits 12,450, bounce rate 32 (%), sessions 4,800, avg duration 2.4 (float). Brand blue-purple --gradient-primary, duration at 1,200 ms for a dynamic feel.
SaaS card — Platform health indicators — Bento with Stats example 2

② SaaS card — Platform health indicators

WhenA summary card inside a product dashboard or status page shows the month's uptime, latency, and deployments.
WhyThe bento groups metrics of different types (percentage, millisecond, integer) into one unified component readable at a glance — progress bars provide relative context instantly, without an extra legend.
SettingsTargets: uptime 99.9 (float), requests 245 (K), latency 48 (ms), deployments 127. Green or blue gradient for positive cells, --fill-width at 99% for uptime, 48% for latency.
"By the numbers" section — Startup / About page — Bento with Stats example 3

③ "By the numbers" section — Startup / About page

WhenA corporate site or investor page showcases the company's headline figures: clients, countries, satisfaction, growth.
WhyCounters entering on scroll turn a cold list of statistics into a narrative moment — each number literally counts up in front of the visitor.
SettingsTargets: clients 3,240, countries 28, satisfaction 97 (%), NPS 71. Subtler palette (gold or monochrome --gradient-primary), --bg-card slightly lighter (#1a1a28) to blend with an off-black background.

How it works

The layout uses a 4-column, 3-row CSS Grid (.bento-stats): cell-1 spans 2 columns × 2 rows, cell-2 spans 2 columns, cell-3 and cell-4 take 1 column each, cell-5 and cell-6 span 2 columns. Numbers (.stat-number) use -webkit-text-fill-color: transparent combined with background-clip: text to render the --gradient-primary gradient as shaped text.

An IntersectionObserver watches #statsBento at a 0.5 threshold: when the grid is 50% visible in the viewport, animateStats() fires once (the observer immediately unsubscribes via unobserve). It reads the data-target attribute on each .stat-number for the target value.

Each counter is driven by a setInterval running 60 steps over 1,500 ms. At each step, current = (target / 60) × step. If the target has a decimal part (target % 1 !== 0), the text is formatted with toFixed(1); otherwise with Math.floor. The interval clears itself at step 60.

Progress bars (.stat-bar-fill) start at width: 0 and animate to width: var(--fill-width) when a cell receives hover or focus (CSS selector .bento-cell:hover .stat-bar-fill). The --fill-width value is set via inline style on each bar — no extra JS required. Transition lasts 1 s with a cubic-bezier(0.16, 1, 0.3, 1) curve.

Accessibility

  • prefers-reduced-motion not handled: the source code contains no check for this media query. Counters animate even when the OS signals a motion-reduction preference. Add a guard if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; at the top of animateStats() to disable it.
  • No role or aria-label on the container: screen readers may announce intermediate values (0 → final value) during animation. Consider adding aria-live="off" to each .stat-number to suppress dynamic updates.
  • Gradient text (-webkit-text-fill-color: transparent) depends on the gradient's luminosity. Verify a ≥ 4.5:1 ratio between the darkest gradient shade and the --bg-card background (#12121a) before changing --gradient-primary.
  • Progress bars are hover-only. Cells are not focusable (tabindex absent), so keyboard users never see the bars. Add :focus-within .stat-bar-fill if keyboard access is required.
  • Without JavaScript, numbers stay at 0 and bars remain hidden — no fatal JS errors, the grid renders correctly with its background and labels.

Browser compatibility

Requires CSS Grid, CSS Custom Properties, IntersectionObserver, and background-clip: text (webkit- prefix included). Zero external dependencies.

Chrome 88+✓ Full
Firefox 87+✓ Full
Safari 15+✓ Full
Edge 88+✓ Full
Mobile iOS✓ Full
Android Chrome✓ Full

Without IntersectionObserver (browsers before 2017), counters never start — numbers stay at 0. An optional polyfill via @types/intersection-observer adds IE11 support. The grid and bars remain visible.

The code

HTML structure to paste into your page (the CSS + JS come with Premium):

index.html — structure
<div class="demo-preview">
  <div class="bento-container">
    <div class="bento-grid bento-stats" id="statsBento">

      <!-- Main cell 2×2 -->
      <div class="bento-cell cell-1">
        <span class="stat-number" data-target="2847">0</span>
        <span class="stat-label">Main label</span>
        <div class="stat-bar">
          <div class="stat-bar-fill" style="--fill-width: 95%;"></div>
        </div>
      </div>

      <!-- 2-column cell -->
      <div class="bento-cell cell-2">
        <span class="stat-number" data-target="156">0</span>
        <span class="stat-label">Label</span>
      </div>

      <!-- 1-column cells -->
      <div class="bento-cell cell-3">
        <span class="stat-number" data-target="98">0</span>
        <span class="stat-label">%</span>
      </div>
      <div class="bento-cell cell-4">
        <span class="stat-number" data-target="42">0</span>
        <span class="stat-label">Label</span>
      </div>

      <!-- 2-column cells with bar -->
      <div class="bento-cell cell-5">
        <span class="stat-number" data-target="1.2">0</span>
        <span class="stat-label">M Label</span>
        <div class="stat-bar">
          <div class="stat-bar-fill" style="--fill-width: 72%;"></div>
        </div>
      </div>
      <div class="bento-cell cell-6">
        <span class="stat-number" data-target="4.9">0</span>
        <span class="stat-label">Rating / 5</span>
        <div class="stat-bar">
          <div class="stat-bar-fill" style="--fill-width: 98%;"></div>
        </div>
      </div>

    </div>
  </div>
</div>
Unlock the full code — $9.90/mo incl. tax

The full HTML + CSS + JS of Bento with Stats, ready to paste — plus all 840 effects. Or $299 once, lifetime access.

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
data-target numeric value Number displayed at animation end on each .stat-number. Integer → Math.floor; decimal → toFixed(1). Examples: 2847, 98, 1.2, 4.9.
--fill-width (inline) 95%, 72%, 98% Final width of the progress bar, set via inline style on each .stat-bar-fill. Example: style="--fill-width: 75%".
--gradient-primary linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #d946ef 100%) Gradient applied to counters (background-clip: text) and progress bars. Swap in your brand colors.
--bg-card #12121a Background of each bento cell. Increase lightness slightly (#1c1c2a) for a less deep dark theme.
--text-muted #8b8b93 Color of stat labels (.stat-label). Use a lighter shade if the card background is darker.
duration (JS, const duration line) 1500 Total counter animation duration in ms. 1,000 ms = sharp and punchy, 2,000 ms = cinematic.
threshold (IntersectionObserver) 0.5 Fraction of the grid that must be visible before triggering. 0 = as soon as one pixel enters, 1 = full grid visible.
grid-template-columns (.bento-stats) repeat(4, 1fr) Column count and proportion. Switch to repeat(3, 1fr) for a more compact grid (adjust grid-column: span N on cells accordingly).

FAQ

Once only. The IntersectionObserver calls statsObserver.unobserve(entry.target) on the first trigger — counters never restart from zero, even if the user scrolls past and returns to the grid.
Add a <div class="bento-cell cell-N"> with a .stat-number[data-target] and a .stat-label. Set its placement in .bento-stats .cell-N via grid-column: span X and/or grid-row: span Y. If you change the column count (grid-template-columns), verify that spans remain consistent with the new grid.
Not via a public API — animateStats is scoped inside the IIFE. Expose it yourself: move the declaration outside the IIFE (window.animateStats = function() { … }), or dispatch a custom event and listen for it before restarting the animation after resetting the numbers to 0.