Social Feed UI Design: Post Cards, Reactions, Infinite Scroll
Build a polished social feed in React — post cards, emoji reactions, and infinite scroll without the jank. Here's how to actually ship it.
What Makes a Social Feed Feel Right
Social feeds are deceptively hard to get right. The UI pattern looks simple — a list of cards — but the details are what separate something that feels like Twitter circa 2012 from something people actually enjoy using. Spacing, hover states, the snap of a reaction button, the way new content appears without yanking you out of your scroll position. Every one of those micro-decisions matters.
Look, most teams spend 80% of their time on data fetching and then throw together the card component in an afternoon. That's backwards. Your users won't care about your GraphQL setup. They'll care that the 'like' button feels satisfying to tap and that posts don't jump around when images load. Get the UI right first.
In practice, you want to think about the feed as three distinct layers: the post card component itself, the reaction/interaction system, and the scroll/pagination mechanism. Build and test them in isolation. Compose them at the end. This article covers all three with actual code you can drop into a real project.
Post Card Component Architecture
The post card is your atomic unit. Everything else composites around it. A solid card has an author row, content area, media slot (optional), and an action bar. That's the skeleton. In React, you'd model it something like this:
interface PostCardProps {
post: {
id: string;
author: { name: string; avatar: string; handle: string };
content: string;
media?: { url: string; alt: string; aspectRatio: number };
timestamp: Date;
reactions: Record<string, number>;
commentCount: number;
shareCount: number;
};
onReact: (postId: string, emoji: string) => void;
onComment: () => void;
}
export function PostCard({ post, onReact, onComment }: PostCardProps) {
return (
<article className="post-card">
<AuthorRow author={post.author} timestamp={post.timestamp} />
<div className="post-content">{post.content}</div>
{post.media && <MediaBlock media={post.media} />}
<ActionBar
reactions={post.reactions}
commentCount={post.commentCount}
shareCount={post.shareCount}
onReact={(emoji) => onReact(post.id, emoji)}
onComment={onComment}
/>
</article>
);
}That aspectRatio field on the media object is critical. Without it, every image load causes a layout shift that'll tank your CLS score. You lock the media slot dimensions before the image arrives — a simple padding trick or the newer aspect-ratio CSS property handles this cleanly. Set your media containers with aspect-ratio: var(--media-ratio, 16/9) and pass the value as a CSS custom property at render time.
Worth noting: the onReact callback is deliberately generic here. Passing the emoji string (not an enum) keeps the component agnostic about what reactions you support. You can swap between thumbs-up-only, Facebook-style, or Discord-style reactions without touching the card component.
One more thing — keep PostCard as a pure presentational component. No data fetching, no global state reads. The parent feed manages state. This makes the card trivial to test and reuse in comment threads, notifications, and anywhere else you need to render a post.
Styling Post Cards: Layout and Visual Hierarchy
The card itself needs enough visual separation from surrounding content without feeling boxed-in. Heavy drop shadows read as outdated. If you want something lighter and more modern, a subtle 1px border at hsl(0 0% 20% / 0.12) in dark mode does the trick. Check out the box shadow generator if you want to dial in that depth feel without going overboard.
.post-card {
background: hsl(0 0% 100%);
border: 1px solid hsl(0 0% 0% / 0.08);
border-radius: 12px;
padding: 16px;
transition: box-shadow 150ms ease;
}
.post-card:hover {
box-shadow: 0 4px 24px hsl(0 0% 0% / 0.06);
}
@media (prefers-color-scheme: dark) {
.post-card {
background: hsl(220 15% 10%);
border-color: hsl(0 0% 100% / 0.08);
}
}Author row spacing is where most implementations get sloppy. You want 12px gap between the avatar and the text column, 4px between the display name and handle. The timestamp sits at the end of the author row, right-aligned, in a muted color. Font size around 13px for the handle and timestamp — small enough to recede, big enough to read.
Honestly, the glassmorphism aesthetic works surprisingly well for social feed cards in 2026, especially when your feed has a gradient or image background. The glassmorphism components and glassmorphism generator can get you a frosted-glass card look in minutes. It gives the feed a layered depth that flat white cards don't have.
For the content area, don't cap text by default. People hate truncated posts. If you need to collapse long posts, use a line-clamp with a 'Show more' toggle — but treat that as a performance optimization for very long content (500+ words), not a blanket rule. Let the card breathe vertically.
Building the Reaction System
Reaction buttons are tiny interactive components that carry a surprising amount of UX weight. The click needs to feel instant — optimistic updates, not wait-for-server. That means you update local state immediately and reconcile with the server response afterward. If the server call fails, roll back. Simple, but teams skip this constantly.
function ActionBar({ reactions, commentCount, onReact }: ActionBarProps) {
const [localReactions, setLocalReactions] = useState(reactions);
const [userReaction, setUserReaction] = useState<string | null>(null);
const handleReact = async (emoji: string) => {
const previous = { reactions: localReactions, userReaction };
// Optimistic update
if (userReaction === emoji) {
setLocalReactions(r => ({ ...r, [emoji]: (r[emoji] ?? 0) - 1 }));
setUserReaction(null);
} else {
if (userReaction) {
setLocalReactions(r => ({ ...r, [userReaction]: (r[userReaction] ?? 0) - 1 }));
}
setLocalReactions(r => ({ ...r, [emoji]: (r[emoji] ?? 0) + 1 }));
setUserReaction(emoji);
}
try {
await onReact(emoji);
} catch {
// Rollback on failure
setLocalReactions(previous.reactions);
setUserReaction(previous.userReaction);
}
};
return (
<div className="action-bar">
{REACTION_EMOJIS.map(emoji => (
<button
key={emoji}
onClick={() => handleReact(emoji)}
className={`reaction-btn ${userReaction === emoji ? 'active' : ''}`}
aria-pressed={userReaction === emoji}
>
<span>{emoji}</span>
<span className="count">{localReactions[emoji] ?? 0}</span>
</button>
))}
<button className="comment-btn" aria-label={`${commentCount} comments`}>
<ChatIcon /> {commentCount}
</button>
</div>
);
}The aria-pressed attribute on the reaction button is non-negotiable if you care about accessibility at all. Screen reader users need to know if they've already reacted. That single attribute communicates toggle state without any extra markup.
For the animation on reaction count change, a simple CSS @keyframes scale bounce on the number span works well. Trigger it by toggling a class. Keep it under 200ms — any longer and it starts feeling playful instead of precise. That said, if you're going for a more expressive UI like a cyberpunk or vaporwave aesthetic, you can push the animation further and it'll feel intentional rather than excessive.
Quick aside: the hover state for reaction buttons is worth the extra 10 minutes. A scale(1.15) transform on hover, 100ms transition, gives the buttons a physical quality that flat opacity changes don't. Users don't consciously notice it, but they feel it.
Infinite Scroll with IntersectionObserver
Infinite scroll gets a bad reputation because it's usually implemented badly — page jumps, broken browser back button, janky loading states. Done right, it's actually the best UX for a social feed. The key is using IntersectionObserver to detect when a sentinel element at the bottom of the list comes into view, then fetching the next page.
function Feed() {
const [posts, setPosts] = useState<Post[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const sentinelRef = useRef<HTMLDivElement>(null);
const fetchMore = useCallback(async () => {
if (loading || !hasMore) return;
setLoading(true);
try {
const { posts: newPosts, nextCursor } = await fetchPosts(cursor);
setPosts(prev => [...prev, ...newPosts]);
setCursor(nextCursor);
setHasMore(nextCursor !== null);
} finally {
setLoading(false);
}
}, [cursor, loading, hasMore]);
useEffect(() => {
const observer = new IntersectionObserver(
entries => { if (entries[0].isIntersecting) fetchMore(); },
{ rootMargin: '200px' } // trigger 200px before the sentinel hits viewport
);
if (sentinelRef.current) observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, [fetchMore]);
return (
<div className="feed">
{posts.map(post => (
<PostCard key={post.id} post={post} onReact={handleReact} onComment={() => {}} />
))}
<div ref={sentinelRef} className="sentinel" />
{loading && <LoadingSkeleton />}
{!hasMore && <EndOfFeed />}
</div>
);
}The rootMargin: '200px' on the observer is the detail that makes infinite scroll feel smooth instead of choppy. You're fetching the next batch before the user hits the bottom, so there's no visible wait. Adjust to 400px on slower connections or data-heavy feeds.
For virtualization on very long feeds (1000+ posts), you'd want something like @tanstack/react-virtual. But don't reach for it by default. DOM performance in 2026 is good enough that you can render 100-200 cards without issues. The complexity of virtualization only pays off at scale. Check out the virtual list guide for a deeper breakdown of when and how to add it.
Loading skeletons deserve real attention. A generic gray block is lazy. Match the skeleton's structure to the actual card — avatar circle, two text lines for the author row, a content text block, and the action bar area. Animate with a shimmer sweep (background-position CSS animation from left to right) rather than a pulse opacity. It reads as 'loading content' rather than 'something is broken'.
Feed-Level State and New Post Injection
What happens when a new post arrives while the user is mid-scroll? This is where most implementations fall apart. You can't prepend it to the top of the list — that shifts every rendered post down and yanks the user out of their reading position. Instagram figured this out around 2018: show a 'N new posts' pill at the top, let the user tap to jump there.
function useLiveUpdates(onNewPost: (post: Post) => void) {
useEffect(() => {
const ws = new WebSocket(process.env.NEXT_PUBLIC_WS_URL!);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'new_post') onNewPost(data.post);
};
return () => ws.close();
}, [onNewPost]);
}
// In the feed component:
const [pendingNew, setPendingNew] = useState<Post[]>([]);
useLiveUpdates(useCallback((post) => {
setPendingNew(prev => [post, ...prev]);
}, []));
// Render a pill above the feed:
{pendingNew.length > 0 && (
<button
className="new-posts-pill"
onClick={() => {
setPosts(prev => [...pendingNew, ...prev]);
setPendingNew([]);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
>
{pendingNew.length} new post{pendingNew.length > 1 ? 's' : ''} — tap to view
</button>
)}That pattern keeps your scroll position stable while making live updates non-disruptive. The pill sits fixed at the top of the feed (not the viewport — fixed to the feed container), so it's visible without blocking content.
Worth noting: if you're not doing real-time updates, polling is fine. setInterval every 30 seconds to check for new posts is perfectly reasonable for most social apps. WebSockets add complexity and infrastructure cost. Don't reach for them unless you actually need sub-second updates.
Feed-level state also needs to handle reactions from other users. If post A shows 42 likes and someone else likes it while you're looking at it, the count should update. That's a separate update channel from new post injection. Handle them independently — don't conflate the 'new content' stream with the 'updated content' stream.
Polish: Transitions, Empty States, and Error Handling
The difference between a feed that ships and a feed that gets praised is the 10% of work nobody talks about. Empty states, error recovery, transition animations when posts enter the list. These are the things that make users trust your product.
For post entry animations, a translateY(12px) to translateY(0) with opacity: 0 to opacity: 1 over 300ms ease-out is the standard. Apply it via a CSS class toggled on mount — not framer-motion unless you're already using it, because 47KB of animation library for a fade-in is hard to justify. The motion for React guide covers when that tradeoff makes sense.
@keyframes postEnter {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.post-card {
animation: postEnter 300ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
.post-card {
animation: none;
}
}The prefers-reduced-motion media query is there. Always. No exceptions. Some users get vertigo from motion. Skipping this is an accessibility failure, not a stylistic choice.
Empty states need copy that isn't 'Nothing here yet.' That's useless. Tell users what to do: 'Follow some people to see their posts here' or 'Your feed is empty — [explore trending content]'. Error states need a retry button. A broken feed with no recovery path is the kind of thing that makes people delete apps. Browse the Empire UI component library for empty state and error state patterns you can adapt — there's no point rebuilding generic UI from scratch.
FAQ
Cursor-based, always. Offset pagination breaks when items are inserted or deleted between page loads — you'll get duplicates or skip posts. A cursor (usually the ID or timestamp of the last fetched post) stays stable regardless of what happens to the data.
Lock the image container dimensions before the image arrives. Store the aspect ratio in your data model and apply it with the CSS aspect-ratio property. Alternatively, use padding-top: calc(100% / aspectRatio) with position: absolute on the image. Either approach reserves the space and eliminates CLS.
Not until you have a real performance problem. Modern browsers handle 100-200 card components fine. Virtualization adds complexity around scroll restoration, dynamic heights, and focus management. Profile first. If you're genuinely seeing jank past 300+ items, then reach for @tanstack/react-virtual.
Keep a snapshot of the previous state before applying the optimistic update. Wrap the API call in a try/catch and restore the snapshot on failure. Toast the user with a brief error message so they know the reaction didn't stick. Don't silently fail — users will wonder why their reaction disappeared.