Glassmorphism Activity Feed: Social Timeline Component
Build a glassmorphism activity feed with frosted-glass timeline cards in React and Tailwind v4. Real code, rgba tokens, and Empire UI components included.
Why Glassmorphism Works for Activity Feeds
Honestly, the activity feed is one of the most underrated surfaces in any product. Everyone races to polish their hero section and forgets that users spend the majority of their time staring at a list of events. That list deserves some visual care.
Glassmorphism is a natural fit here. Each feed item floats as its own frosted card — distinct, light, readable — while the background bleeds through just enough to reinforce context. You're not fighting the content. You're framing it.
If you want to understand where this aesthetic comes from before building, what is glassmorphism covers the spec in depth. The short version: backdrop-filter: blur(12px), a semi-transparent background, a thin light border, and a subtle drop shadow. That's the whole recipe.
The challenge with activity feeds specifically is density. You might have 50 items. Cards that are too heavy will collapse into a wall of noise. Glass keeps things airy because it never fully competes with the content sitting on top of it.
Anatomy of a Glass Activity Card
Before writing any code, nail down the structure. A single feed item needs four things: an avatar or icon on the left, a timestamp top-right, an action summary in the body, and an optional metadata row at the bottom — likes, comments, a link. That's it. Don't add more.
The glass layer sits behind all of that. In practice this means a container with background: rgba(255,255,255,0.10), backdrop-filter: blur(14px), border: 1px solid rgba(255,255,255,0.18), and border-radius: 16px. Those exact values matter. Go much higher on the background alpha and you lose the glass feel entirely. Go lower and contrast suffers on light backgrounds.
One thing people miss: the glass effect only reads correctly when there's something to blur behind it. A flat #0f0f0f background defeats the whole point. You need a gradient, an image, or at minimum some colour variation behind your feed. Even a simple linear-gradient(135deg, #1e1b4b, #312e81) from Tailwind's indigo range works well.
Building the Component in React and Tailwind v4
Here's the base component. This is written for Tailwind v4.0.2 using the new @utility layer and native CSS variables. No extra plugins needed — backdrop-blur is first-class in v4.
import React from 'react';
interface FeedItem {
id: string;
user: { name: string; avatar: string };
action: string;
target: string;
timestamp: string;
meta?: { likes: number; comments: number };
}
function GlassActivityCard({ item }: { item: FeedItem }) {
return (
<div
className="
relative rounded-2xl px-5 py-4
border border-white/20
bg-white/10 backdrop-blur-[14px]
shadow-[0_4px_24px_rgba(0,0,0,0.18)]
transition-transform duration-200 hover:-translate-y-0.5
"
>
<div className="flex items-start gap-3">
<img
src={item.user.avatar}
alt={item.user.name}
className="h-9 w-9 rounded-full ring-1 ring-white/30 shrink-0"
/>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<p className="truncate text-sm font-semibold text-white">
{item.user.name}
</p>
<span className="shrink-0 text-xs text-white/50">{item.timestamp}</span>
</div>
<p className="mt-0.5 text-sm text-white/80">
{item.action}{' '}
<span className="font-medium text-white">{item.target}</span>
</p>
{item.meta && (
<div className="mt-2 flex gap-4 text-xs text-white/50">
<span>{item.meta.likes} likes</span>
<span>{item.meta.comments} comments</span>
</div>
)}
</div>
</div>
</div>
);
}
export function GlassmorphismActivityFeed({ items }: { items: FeedItem[] }) {
return (
<div className="flex flex-col gap-3">
{items.map((item) => (
<GlassActivityCard key={item.id} item={item} />
))}
</div>
);
}Notice the gap-3 on the outer flex column — that's 12px between cards. You could push it to gap-4 (16px) for more breathing room, but any more than that and the feed starts to feel disconnected. The hover:-translate-y-0.5 is a 2px lift on hover, just enough to feel interactive without being theatrical.
Handling the Timeline Connector Line
Most activity feeds use a vertical line running through the avatar column to connect events. With glass cards this gets tricky — the line needs to sit behind the cards visually but still look continuous. The cleanest approach is a pseudo-element on the feed wrapper rather than individual card borders.
/* In your global CSS or a Tailwind @layer */
@layer components {
.glass-feed {
position: relative;
}
.glass-feed::before {
content: '';
position: absolute;
/* 20px = half avatar width (36px / 2) + 20px left padding */
left: 40px;
top: 20px;
bottom: 20px;
width: 1px;
background: linear-gradient(
to bottom,
transparent,
rgba(255, 255, 255, 0.25) 15%,
rgba(255, 255, 255, 0.25) 85%,
transparent
);
pointer-events: none;
}
}That gradient fade at the top and bottom of the line — transparent at 0% and 100% — is what makes it feel polished rather than clunky. Hard line endings on a glass UI always look amateur. The left: 40px value assumes 20px of card padding plus a 36px avatar, centering the line right down the avatar column.
You'll also want to apply z-index: 0 to the wrapper and z-index: 1 to each card so the cards stack visually on top of the connector. In Tailwind that's just adding relative z-10 to GlassActivityCard and relative z-0 to the feed wrapper.
Dark Mode and Colour Token Setup
If you're already using a theme toggle in React, your glass feed needs to adapt to light mode too. The default rgba(255,255,255,0.10) background works beautifully on dark. On light it reads as almost fully transparent, which can kill contrast.
The fix is CSS custom properties scoped to [data-theme] or the native :root / .dark selectors Tailwind v4 uses. Define two sets of tokens and swap them out — the Tailwind classes stay identical, the values change underneath.
:root {
--glass-bg: rgba(255, 255, 255, 0.70);
--glass-border: rgba(0, 0, 0, 0.08);
--glass-text: rgba(0, 0, 0, 0.85);
--glass-text-muted: rgba(0, 0, 0, 0.45);
}
.dark {
--glass-bg: rgba(255, 255, 255, 0.10);
--glass-border: rgba(255, 255, 255, 0.18);
--glass-text: rgba(255, 255, 255, 0.95);
--glass-text-muted: rgba(255, 255, 255, 0.50);
}Then in your component swap the hardcoded bg-white/10 for bg-[var(--glass-bg)] and repeat the pattern for border and text colours. It's a bit more verbose in JSX, but you get a properly dual-mode glass component that doesn't look broken in either context. Worth the few extra characters.
Performance: Blur is Not Free
Here's the thing: backdrop-filter: blur() is one of those CSS properties that will absolutely destroy your frame rate if you're not careful. The browser needs to sample and blur everything behind the element on every paint. Stack 50 of those on a page and scroll performance tanks on mid-range Android devices.
What's the threshold? Empirically, around 20-30 blurred elements visible at once starts to hurt on devices below ~Snapdragon 730 class hardware. For a paginated feed — 15 items per page — you're fine. For a virtualized infinite scroll showing 50+ cards simultaneously, you need to think about this.
The pragmatic options: reduce the blur radius (dropping from blur(14px) to blur(8px) cuts GPU cost meaningfully with minimal visual difference), use will-change: transform on the feed wrapper to hint the compositor, or fall back to a solid bg-white/15 with no blur for users who prefer reduced motion via @media (prefers-reduced-motion: reduce). That last one also covers accessibility — some vestibular disorder users report discomfort from heavy blur effects.
Also compare this aesthetic against other morphism trends — glassmorphism vs neumorphism has a good breakdown of where each one earns its performance budget.
Animation: Making Feed Items Feel Alive
A static glass feed looks fine. An animated one looks great. The trick is restraint — you want items to enter smoothly, not to perform a circus act every time someone scrolls.
The pattern that works best is a staggered fade-up on mount. Each card translates from translateY(8px) to translateY(0) over 250ms, with a delay of index * 40ms. So the first card starts immediately, the fifth card waits 160ms. By the time the tenth card animates in, the whole feed feels like it flowed into existence rather than snapping onto the screen.
In React you can drive this with a simple useEffect that adds an is-visible class after mount, or use Framer Motion's staggerChildren if it's already in your stack. Don't add Framer just for this — the CSS approach with animation-delay: calc(var(--index) * 40ms) and a CSS variable set via inline styles is totally adequate and ships zero extra JavaScript.
If you want to go further — real-time feeds where new items push in from the top — flip the animation. New items enter from translateY(-12px) with an opacity transition, existing items shift down. Keep the duration under 200ms or users will feel like the interface is fighting them.
Dropping It Into Empire UI
Empire UI ships a ready-made glass card primitive that matches the tokens above. If you're starting from scratch on the feed, you can grab the free glassmorphism components overview to see what's already available before writing your own.
The library's GlassCard component accepts a blur prop (number, defaults to 14) and a tint prop ('light' | 'dark' | 'none'). Wrap each FeedItem in a GlassCard instead of the raw div and you get the correct tokens, focus ring styles, and reduced-motion handling for free. The feed wrapper itself stays plain — no Empire UI wrapper needed there, just a <div className="flex flex-col gap-3"> and the connector CSS from the earlier section.
Empire UI also provides an ActivityFeedItem component at the atomic level — avatar, action text, timestamp, and metadata slots are all exposed as typed props. Composing the full feed becomes maybe 30 lines of code rather than 150. The styles are all Tailwind utility classes, so you can override anything with standard Tailwind config without fighting a CSS-in-JS layer.
FAQ
Yes. backdrop-filter has been supported in Chrome, Edge, Firefox, and Safari for several years. Firefox required a flag until version 103; it's unflagged and stable now. You don't need a polyfill — just add @supports (backdrop-filter: blur(1px)) guards if you want a solid-background fallback for very old browsers.
Correct — backdrop-blur-{size} utilities are built into Tailwind v4 core. You can use backdrop-blur-[14px] for arbitrary values or the scale classes like backdrop-blur-md (which maps to 12px). No tailwindcss-filters plugin needed; that was only relevant in older Tailwind v2 setups.
You can't, really — glass needs visual content behind it to refract. On light pages, use rgba(255,255,255,0.70) with a very light blur(8px) paired with a box-shadow to create depth, or add a subtle gradient to the page background. Pure white with a glass overlay just looks like a slightly lighter white box.
Two things matter: contrast and motion. Check text contrast against the blurred background sample at its lightest point — don't just test the average. Use prefers-reduced-motion to disable blur animations and stagger delays. Consider also offering a prefers-contrast: high media query that replaces semi-transparent backgrounds with fully opaque, high-contrast equivalents.
Each card should have its own. A single backdrop-filter on the feed container would blur everything inside it, including your text and avatars, which is not what you want. The per-card approach means each card independently composites its background layer, which is the correct behaviour.
Prepend new items to the top of the array and animate them in from opacity: 0, translateY(-10px). To prevent layout shift on the existing items, wrap the feed in a container with a fixed height and overflow-y: auto, or use a virtualized list library like react-window or tanstack-virtual. Without virtualization, DOM growth on a real-time feed will eventually cause jank regardless of glass styling.