Glassmorphism Stats Widget: KPI Cards With Frosted Glass and Glow
Build frosted-glass KPI stat cards with backdrop-filter, glow borders, and animated counters. Full React + Tailwind code included.
Why KPI Cards and Glassmorphism Go Together
Stats widgets are one of the few places in UI design where you actively want the background to show through. You want context — the gradient behind the card, the color of the section, the visual depth — because that context tells the user something is live, something is dynamic. Flat white boxes kill that. Frosted glass doesn't.
Glassmorphism has been gaining real traction since 2021, but the 2026 version of it is sharper. Better browser support for backdrop-filter, cleaner glow implementations, and the shift to OKLCH color spaces mean you can now push the effect further without the GPU thrash you'd have worried about two years ago. Glassmorphism components have matured into production-viable patterns — not just dribbble-bait.
In practice, frosted KPI cards work best when you have three things: a gradient or image background with enough contrast, a blur radius between 12px and 20px, and a semi-transparent border that catches the light. Get those three right and you'll have cards that look genuinely premium, not gimmicky.
Honestly, the biggest mistake developers make is treating the blur as the whole effect. The blur is just the base. The glow — a low-opacity box-shadow in a complementary hue — is what makes the card feel alive, especially in dark UIs.
The CSS Foundation: backdrop-filter, glow, and borders
Start with backdrop-filter: blur(16px) saturate(180%). The saturation boost is underrated — it makes the blurred background pop instead of looking washed out. You'll also want background: rgba(255,255,255,0.08) for dark themes, or rgba(255,255,255,0.55) for light ones. Yes, the difference between 0.08 and 0.12 matters visually at scale.
The border is where people cheap out. A 1px solid rgba(255,255,255,0.18) border is fine, but a subtle inset shadow on the top-left edge makes it look like the card has thickness. Combine that with a box-shadow in a brand color at 30–40% opacity for the glow:
``css
.glass-kpi-card {
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 16px;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.3),
0 0 0 1px rgba(255, 255, 255, 0.06) inset,
0 0 24px rgba(99, 102, 241, 0.35);
padding: 24px;
position: relative;
overflow: hidden;
}
``
Worth noting: the -webkit-backdrop-filter prefix is still needed as of mid-2026 for older iOS Safari versions. Don't drop it. overflow: hidden on the card also prevents glow from bleeding outside the border radius in some Chromium builds.
One more thing — if you want the card to feel more like frosted glass and less like a blurry box, add a subtle noise texture overlay. A 3% opacity SVG noise or CSS filter: url(#noise) on a pseudo-element goes a long way. The glassmorphism generator lets you preview these combinations in real time without touching code.
Building the React KPI Card Component
Here's the base component. It takes a label, value, delta, and optional icon. The delta drives the glow color — green glow for positive, amber for neutral, red for negative. That's the kind of semantic feedback that makes dashboards readable at a glance.
``tsx
import { type ReactNode } from 'react'
import { cn } from '@/lib/utils'
interface KpiCardProps {
label: string
value: string | number
delta?: number
icon?: ReactNode
className?: string
}
const deltaGlow = (delta?: number) => {
if (delta === undefined) return 'shadow-[0_0_24px_rgba(99,102,241,0.35)]'
if (delta > 0) return 'shadow-[0_0_24px_rgba(34,197,94,0.4)]'
if (delta < 0) return 'shadow-[0_0_24px_rgba(239,68,68,0.4)]'
return 'shadow-[0_0_24px_rgba(234,179,8,0.35)]'
}
export function KpiCard({ label, value, delta, icon, className }: KpiCardProps) {
return (
<div
className={cn(
'relative overflow-hidden rounded-2xl border border-white/15 bg-white/[0.08] p-6',
'backdrop-blur-xl backdrop-saturate-[180%]',
'shadow-[0_8px_32px_rgba(0,0,0,0.3),0_0_0_1px_rgba(255,255,255,0.06)_inset]',
deltaGlow(delta),
className,
)}
>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium text-white/60">{label}</p>
<p className="mt-1 text-3xl font-bold tracking-tight text-white">{value}</p>
{delta !== undefined && (
<p className={cn(
'mt-1 text-xs font-medium',
delta > 0 ? 'text-green-400' : delta < 0 ? 'text-red-400' : 'text-yellow-400',
)}>
{delta > 0 ? '+' : ''}{delta}% vs last period
</p>
)}
</div>
{icon && (
<div className="rounded-xl bg-white/10 p-3 text-white/70">
{icon}
</div>
)}
</div>
</div>
)
}
``
The deltaGlow function switches the box-shadow color based on trend direction. It's a small thing but it means your CMO can read the dashboard at 9am without having to actually read the numbers. Color does the work.
Quick aside: backdrop-blur-xl in Tailwind v4 maps to blur(24px). If that's too heavy for your specific background (say, a light gradient with low saturation), drop it to backdrop-blur-lg which gives you 16px. The threshold where blur starts looking muddy versus intentional is usually around 8–12px — below that it just looks like a browser rendering bug.
Animated Number Counter
Static numbers in a stats widget feel inert. An animated counter on mount — the number ticking up from 0 to its final value — adds a dopamine hit that keeps the dashboard feeling alive. You don't need a library for this. A useEffect with requestAnimationFrame covers it:
``tsx
import { useEffect, useRef, useState } from 'react'
export function useCountUp(target: number, duration = 1200) {
const [value, setValue] = useState(0)
const startTime = useRef<number | null>(null)
useEffect(() => {
startTime.current = null
const tick = (ts: number) => {
if (!startTime.current) startTime.current = ts
const elapsed = ts - startTime.current
const progress = Math.min(elapsed / duration, 1)
// Ease out cubic
const eased = 1 - Math.pow(1 - progress, 3)
setValue(Math.round(eased * target))
if (progress < 1) requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
}, [target, duration])
return value
}
``
Then in your KpiCard, swap the static value for useCountUp(numericValue). The ease-out cubic (1 - (1-t)^3) makes numbers decelerate at the end, which feels natural. Linear interpolation feels mechanical and users notice even if they don't know why.
That said, if the user navigates away and back, you don't want the counter restarting constantly. Wrap the KpiCard in an IntersectionObserver trigger so the animation only fires when the card enters the viewport. The scroll-reveal-animation-react article covers that pattern in detail if you need a reference.
For currency values, format with Intl.NumberFormat instead of manual string manipulation. new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(value) handles commas, symbols, and locale automatically — and it's been reliable since Node 12.
Composing a Full Stats Grid
A single card is a demo. A grid is a product. Here's a full stats section with a gradient background that the glass cards blur through:
``tsx
import { Users, TrendingUp, DollarSign, Activity } from 'lucide-react'
import { KpiCard } from './KpiCard'
const stats = [
{ label: 'Total Revenue', value: '$124,800', delta: 12.4, icon: <DollarSign size={20} /> },
{ label: 'Active Users', value: '8,421', delta: 5.1, icon: <Users size={20} /> },
{ label: 'Conversion Rate', value: '3.62%', delta: -1.3, icon: <TrendingUp size={20} /> },
{ label: 'Uptime', value: '99.97%', delta: 0, icon: <Activity size={20} /> },
]
export function StatsGrid() {
return (
<div className="relative min-h-screen bg-gradient-to-br from-slate-900 via-indigo-950 to-slate-900 p-8">
{/* Background glow blobs */}
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="absolute -left-32 top-0 h-96 w-96 rounded-full bg-indigo-600/30 blur-[120px]" />
<div className="absolute right-0 top-1/3 h-72 w-72 rounded-full bg-violet-600/20 blur-[100px]" />
</div>
<div className="relative grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{stats.map((stat) => (
<KpiCard key={stat.label} {...stat} />
))}
</div>
</div>
)
}
``
Those background blobs are doing heavy lifting. Without them — without something for the backdrop-filter to blur — the frosted glass effect is invisible. The blur needs contrast and color in the layer behind it. This is why glassmorphism on a flat #1e1e2e background doesn't work. You need gradients, blobs, or a textured image.
Look, the blob approach with blur-[120px] is dead simple but it works. If you want more sophisticated backgrounds, check out Empire UI's glassmorphism components — the pre-built backgrounds there are already optimized for layering glass elements on top of them. No need to reinvent the gradient math.
For responsive behavior: xl:grid-cols-4 breaks to 2 columns on tablet and 1 on mobile. On mobile you might want to reduce the card padding from p-6 to p-4 and the blur from backdrop-blur-xl to backdrop-blur-lg — smaller screens can have weaker GPUs and blur(24px) on a 390px-wide phone isn't free.
Performance and Accessibility Considerations
Here's the honest performance picture: backdrop-filter triggers GPU compositing on every card. On a grid of 8+ cards with overlapping blur regions, you can hit frame drops on mid-range Android devices. The fix is will-change: backdrop-filter on the card and making sure the backdrop blob elements have isolation: isolate to contain their stacking context.
Use @media (prefers-reduced-motion: reduce) to disable the count-up animation for users who've opted out of motion. The number should just snap to its final value. Motion preferences aren't optional — they're part of basic accessibility practice:
``css
@media (prefers-reduced-motion: reduce) {
.kpi-counter {
transition: none !important;
animation: none !important;
}
}
``
For screen readers, the label-value relationship needs to be explicit. Don't rely on visual layout. Use aria-label or a visually-hidden span to give context: aria-label="Total Revenue: $124,800, up 12.4% vs last period". A screen reader user navigating a dashboard deserves the same information a sighted user gets from the color-coded delta.
Worth noting: the delta color coding (green/red) relies entirely on color to convey meaning, which fails WCAG 1.4.1. Add the arrow icon or explicit text like "up" / "down" alongside the percentage. The code examples above already include the sign (+12.4% / -1.3%) which helps, but an up-arrow icon paired with it is better.
On the tools side, if you need to tune glow values and border opacities without editing code constantly, the box shadow generator lets you tweak multi-layer shadows live and copy the CSS output. Way faster than guessing values in DevTools.
Extending the Pattern: Sparklines and Trend Indicators
A number and a delta percentage are useful. A sparkline — a tiny inline chart showing the trend over the last 30 days — is better. You can embed a minimal SVG sparkline directly in the card without pulling in a full charting library:
``tsx
interface SparklineProps {
data: number[]
width?: number
height?: number
color?: string
}
export function Sparkline({ data, width = 80, height = 32, color = '#818cf8' }: SparklineProps) {
const max = Math.max(...data)
const min = Math.min(...data)
const range = max - min || 1
const points = data
.map((v, i) => {
const x = (i / (data.length - 1)) * width
const y = height - ((v - min) / range) * height
return ${x},${y}
})
.join(' ')
return (
<svg width={width} height={height} viewBox={0 0 ${width} ${height}} fill="none">
<polyline
points={points}
stroke={color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
opacity={0.8}
/>
</svg>
)
}
``
Drop the Sparkline into the bottom of your KpiCard with 80x32px dimensions and it fits cleanly inside the card without overwhelming the main number. Pass color based on the delta direction — the same green/red/amber logic — so the sparkline matches the glow color. Tight, coherent design.
That said, for production dashboards where you need interactivity (hover tooltips, zoom, brushing), you'll want Recharts or a lightweight alternative. The glassmorphism-charts-react article covers layering Recharts on frosted-glass backgrounds if you need to go deeper than sparklines.
One more thing — don't forget loading states. When data is fetching, a skeleton placeholder should match the card shape exactly: same border radius, same dimensions, same glass background, just with a shimmer animation over the content area. Jarring layout shifts between skeleton and loaded state break the illusion of a polished dashboard. The Empire UI component library has skeleton primitives you can adapt.
FAQ
Yes. As of 2026, backdrop-filter has 96%+ global browser support. You still need -webkit-backdrop-filter for older iOS Safari. The only real gap is Firefox on Linux with GPU compositing disabled — a niche edge case worth a @supports fallback.
Add colored blur blobs or a gradient behind your cards — the backdrop-filter needs something visually rich to blur. A flat single-color background makes the effect invisible. Aim for at least 2–3 distinct hues in the background layer.
Yes, but you need higher background opacity — try rgba(255,255,255,0.55) to 0.7 — and a more subtle glow. Light glass works best with pastel blob backgrounds and a thin rgba(0,0,0,0.08) border instead of a white one.
Between 12px and 20px for most dashboards. Below 8px it looks like a rendering glitch rather than intentional frosting. Above 24px it starts eating GPU time noticeably on mobile. blur(16px) with saturate(180%) is a solid default.