EmpireUI
Get Pro
← Blog7 min read#glassmorphism#stat-widget#kpi-cards

Glassmorphism Stats Widget: KPI Cards for Admin Dashboards

Build glassmorphism KPI stat cards for admin dashboards with Tailwind v4 and React. Frosted glass effects, live data bindings, and accessible color contrast covered.

Frosted glass KPI stat cards displayed on a dark gradient admin dashboard

Why Glassmorphism Works for Stat Widgets

Honestly, most admin dashboards are visually exhausting. Dense grids, flat colored cards, zero depth — your eye doesn't know where to look. A glassmorphism stat widget changes that immediately. The frosted-glass surface naturally pulls focus without shouting.

The trick is that glass cards create a visual hierarchy layer between the background and the number. When you've got a dashboard with 6–8 KPI metrics crammed above the fold, that depth signal is genuinely useful. Users parse the layout faster.

If you want to understand what glassmorphism actually is before building with it, that article covers the design language origins. But for this piece, we're going straight to implementation — stat cards, data binding, the works.

Glass effects also age well in dark-mode-first products. SaaS tools, analytics platforms, internal ops dashboards — they all live in dark environments where backdrop-filter: blur() genuinely shines. Light mode is a harder sell, but we'll cover that too.

The Core CSS: backdrop-filter and rgba Backgrounds

Everything starts with two CSS properties: backdrop-filter: blur(12px) and a semi-transparent background using rgba(255,255,255,0.1). That's it. The rest is refinement.

In Tailwind v4.0.2 you get backdrop-blur-md which maps to blur(12px). That's usually your sweet spot for stat cards — enough frosting to read as glass, not so heavy that it kills performance on lower-end GPUs. If you're stacking multiple cards, don't go above blur(16px) without profiling first.

The border is what separates a good glass card from a bad one. A single-pixel border at rgba(255,255,255,0.25) on the top and left edges mimics how light catches real glass. Skip this and your cards just look like washed-out boxes.

Building the StatCard Component in React

Here's a production-ready StatCard component. It accepts a label, value, trend percentage, and an icon slot. It's intentionally minimal — no animation library dependency, no massive prop API.

import { ReactNode } from 'react';

interface StatCardProps {
  label: string;
  value: string | number;
  trend?: number; // positive = up, negative = down
  icon?: ReactNode;
}

export function StatCard({ label, value, trend, icon }: StatCardProps) {
  const trendColor =
    trend === undefined
      ? 'text-white/50'
      : trend >= 0
      ? 'text-emerald-400'
      : 'text-rose-400';

  const trendLabel =
    trend !== undefined
      ? `${trend >= 0 ? '+' : ''}${trend}%`
      : null;

  return (
    <div
      className="
        relative rounded-2xl p-6
        bg-white/10 backdrop-blur-md
        border border-white/20
        shadow-[inset_0_1px_0_rgba(255,255,255,0.15)]
        text-white
      "
    >
      {icon && (
        <div className="mb-3 w-9 h-9 flex items-center justify-center
          rounded-xl bg-white/15 text-white/80">
          {icon}
        </div>
      )}
      <p className="text-sm font-medium text-white/60 tracking-wide uppercase">
        {label}
      </p>
      <p className="mt-1 text-3xl font-bold tabular-nums">{value}</p>
      {trendLabel && (
        <p className={`mt-2 text-sm font-medium ${trendColor}`}>
          {trendLabel} vs last period
        </p>
      )}
    </div>
  );
}

A few things worth noting here. The shadow-[inset_0_1px_0_rgba(255,255,255,0.15)] is doing real work — it's that top-edge highlight that makes the card read as a physical surface. Also tabular-nums on the value prevents layout shift when numbers tick up or down in a live dashboard. Small detail, big difference.

The icon slot takes any ReactNode — Lucide icons, Hero icons, inline SVGs, whatever you're already using. No lock-in.

Laying Out a KPI Grid with Tailwind's Gap Utilities

Four cards across on desktop, two on tablet, one on mobile. That's the standard admin dashboard grid. With Tailwind v4 you can do this in one className string — no custom breakpoint config needed.

export function KpiGrid({ stats }: { stats: StatCardProps[] }) {
  return (
    <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
      {stats.map((stat) => (
        <StatCard key={stat.label} {...stat} />
      ))}
    </div>
  );
}

The gap-4 gives you 16px spacing between cards. Some teams prefer gap-5 (20px) on wider dashboards — it breathes more. Go tighter than gap-3 and the glass borders start bleeding into each other visually. Not a disaster, just looks crowded.

One thing people often miss: the parent container needs a background. Glass cards are transparent — if you drop them on a white background they look terrible. You want a dark gradient or a rich image behind them. bg-gradient-to-br from-slate-900 to-indigo-950 is a reliable starting point that doesn't fight the cards.

Animating the Numbers: Counting Up on Mount

Static numbers on a dashboard feel dead. A simple count-up animation on mount — from 0 to the target value over 800ms — makes the data feel alive without being distracting. You don't need a library for this.

import { useEffect, useState, useRef } from 'react';

function useCountUp(target: number, duration = 800) {
  const [current, setCurrent] = useState(0);
  const startTime = useRef<number | null>(null);
  const animFrame = useRef<number>(0);

  useEffect(() => {
    const tick = (timestamp: number) => {
      if (!startTime.current) startTime.current = timestamp;
      const elapsed = timestamp - startTime.current;
      const progress = Math.min(elapsed / duration, 1);
      // ease-out cubic
      const eased = 1 - Math.pow(1 - progress, 3);
      setCurrent(Math.round(eased * target));
      if (progress < 1) animFrame.current = requestAnimationFrame(tick);
    };
    animFrame.current = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(animFrame.current);
  }, [target, duration]);

  return current;
}

Pass the raw number into useCountUp and render the returned current value instead. The cubic ease-out feels natural — it accelerates fast and slows into the final value. Linear interpolation feels mechanical in comparison.

Should you animate every number? No. Revenue figures yes, boolean status fields no. Use judgment. An animated 'Active: 1' counter is just noise.

Accessibility and Contrast in Frosted Glass Cards

Here's the thing: glassmorphism has a real accessibility problem out of the box. text-white/60 on a bg-white/10 surface over a dark background often fails WCAG AA contrast ratios. You need to check this — don't just eyeball it.

The label text at text-white/60 (roughly rgba(255,255,255,0.6)) typically lands around a 3.8:1 contrast ratio against a dark background showing through the glass. That's below the 4.5:1 minimum for normal text. Bump it to text-white/75 and you're usually compliant. Test with the exact gradient you're using — the backdrop blur changes perceived contrast.

The value text at full white (text-white) on that semi-transparent surface is fine — it usually clears 7:1 easily. The trend percentage text in text-emerald-400 is where people get burned. Emerald 400 on dark backgrounds is around 4.6:1, which is passing but tight. If your background is lighter than slate-800, switch to emerald-300.

If you're comparing glassmorphism to other trends like neumorphism, accessibility is where glass usually wins — neumorphic designs have chronically bad contrast because the whole effect depends on low-contrast shadows.

Connecting to Live Data: SWR and WebSocket Patterns

A stat widget that shows yesterday's data isn't particularly useful. Most admin dashboards want near-real-time KPIs. Two patterns dominate: polling with SWR, and WebSocket push for sub-second freshness.

For polling, SWR with a 30-second refresh interval covers 90% of use cases. Your endpoint returns { revenue: 48291, orders: 1204, churn: 2.3, activeUsers: 891 } and you map that into StatCardProps[]. Simple.

WebSocket is for live dashboards where the number needs to update the moment it changes — think ops monitoring, live sales tickers, support queue depth. In those cases you're maintaining a connection and pushing diffs. The useCountUp hook handles the animation smoothly because each new target triggers a fresh animation from the previous displayed value, not from zero. That's the behavior you want — it feels like a counter ticking, not resetting.

Don't mix polling and WebSocket on the same dashboard without thinking it through. You'll get race conditions where the socket updates a value that the poll then reverts. Pick one pattern per data source and stick to it.

Customizing Glass Cards per Style — Empire UI's 40 Variants

Empire UI ships glassmorphism as one of its 40 visual styles, but the stat card pattern works across several of them. The best free glassmorphism components roundup covers what's available across the ecosystem, but Empire UI's implementation is worth understanding specifically.

In Empire UI, the style token for glass is applied at the theme level. You switch the entire dashboard between glass, neumorphism, neobrutalism, or any of the other 37 styles by changing a single data-style attribute on a wrapper element. The stat cards don't have per-style logic inside them — the CSS cascade handles it. That means you can offer a style switcher to your users without rewriting the component tree. If you're building a theme toggle in React, Empire UI's style system hooks into that pattern cleanly.

For teams that want to go deeper on the glass effect — adjusting blur radius, border opacity, or background tint per card — Empire UI exposes CSS custom properties: --glass-bg, --glass-blur, and --glass-border-opacity. Override them at the component level for accent cards like a 'Total Revenue' card that you want slightly more opaque than the rest.

What makes the glass variant different from the flat or neobrutalism variants isn't just aesthetics. The layering model changes how you handle hover states, how shadows work, and how you'd approach dark-mode inversion. Worth spending time with if you're building something production-grade.

FAQ

Why does backdrop-filter blur look different in Firefox vs Chrome?

Firefox's implementation of backdrop-filter has historically been behind a feature flag and renders the blur slightly differently at certain pixel densities. As of Firefox 103+ it's enabled by default, but you'll still see minor rendering differences at blur values above 20px. Stick to blur(12px) or blur(16px) and the gap is negligible in practice.

Can I use glassmorphism stat cards on a white or light background?

You can, but it's a fight. The frosted glass effect reads as depth because the blurred background has visible color variation. On a flat white background there's nothing to blur — your card just looks like a slightly off-white box with a border. If you need light mode, use a subtle gradient or a textured background image behind the cards, not a flat white surface.

How do I prevent layout shift when numbers animate from 0 to their value?

Two things: use tabular-nums on the value element so digit widths don't vary, and set a min-width on the value container that accommodates the widest expected number. If your revenue figure peaks at 7 digits, size for 7 digits from the start. Otherwise the card width jumps during the count-up animation.

Does backdrop-filter hurt performance on dashboards with 8+ glass cards?

It depends on the GPU and the blur radius. On modern hardware, 8 cards at blur(12px) is fine. The cost scales with the number of composited layers, not just card count. Avoid nesting glass cards inside other glass containers — that doubles the compositing cost. Also make sure the cards use will-change: transform or are promoted to their own layer with transform: translateZ(0) if you're animating them.

What's the right blur radius for stat cards — 8px, 12px, or 20px?

12px is the sweet spot for cards. 8px reads as barely frosted — you'll see it on mobile where performance matters more. 20px starts to look like a loading blur and hurts legibility of the content behind. Use 12px as your default, drop to 8px on mobile via a responsive class, and only go to 20px for modal overlays or large hero panels.

How do I handle the glass effect when the background image changes dynamically?

backdrop-filter automatically reblurs whatever is behind the element at render time — you don't have to do anything special. If you're crossfading background images, the blur smoothly transitions as the background changes because it's computed live by the compositor. Just make sure your card's z-index is above the background element and you're set.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Dark Glassmorphism Dashboard: Admin UI That Impresses ClientsGlassmorphism Sidebar Navigation: Frosted Glass Done RightGlassmorphism vs Solid Design: Which Converts Better in 2027Glassmorphism Card Hover: Blur Depth Change on Mouse Enter