Bento with Stats
Bento stats grid: counters animate on scroll via IntersectionObserver, progress bars reveal on hover with a CSS transition.
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



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 ofanimateStats()to disable it. - No
roleoraria-labelon the container: screen readers may announce intermediate values (0 → final value) during animation. Consider addingaria-live="off"to each.stat-numberto 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-cardbackground (#12121a) before changing--gradient-primary. - Progress bars are hover-only. Cells are not focusable (
tabindexabsent), so keyboard users never see the bars. Add:focus-within .stat-bar-fillif 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.
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):
<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>
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 / property | Default | Effect |
|---|---|---|
| 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
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.<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.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.