Social Post Card
Original · freeA social media post card with image, likes and comments.
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/card-social-post.json"use client";
import { useId, useRef, useState, type FormEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Comment = {
id: string;
author: string;
handle: string;
avatar: string;
text: string;
time: string;
liked: boolean;
};
type Post = {
id: string;
author: string;
handle: string;
avatar: string;
verified: boolean;
time: string;
location: string;
image: string;
alt: string;
caption: string;
tags: string[];
likes: number;
comments: Comment[];
};
const POSTS: Post[] = [
{
id: "post-aurora",
author: "Mara Delacroix",
handle: "maradelacroix",
avatar: "/img/gallery/g12.webp",
verified: true,
time: "3h",
location: "Tromsø, Norway",
image: "/img/gallery/g07.webp",
alt: "Green aurora borealis rippling over a snow-covered fjord at night",
caption:
"Stood in -19°C for four hours waiting on the cloud break. The sky finally split open at 2:41am and this happened. Worth every frozen finger.",
tags: ["nightphotography", "aurora", "sonyA7IV"],
likes: 2841,
comments: [
{
id: "c1",
author: "Kite & Compass Co.",
handle: "kitecompass",
avatar: "/img/gallery/g21.webp",
text: "The reflection on the water is unreal. What ISO did you land on?",
time: "2h",
liked: false,
},
{
id: "c2",
author: "Devon Park",
handle: "devonshoots",
avatar: "/img/gallery/g30.webp",
text: "Saving this for my Iceland trip moodboard. Absolute masterclass.",
time: "1h",
liked: true,
},
],
},
{
id: "post-atlas",
author: "Atlas Roasters",
handle: "atlasroasters",
avatar: "/img/gallery/g18.webp",
verified: false,
time: "1d",
location: "Portland, OR",
image: "/img/gallery/g25.webp",
alt: "Overhead shot of a pour-over coffee setup on a walnut counter",
caption:
"New single-origin just dropped: Ethiopia Guji, washed. Notes of jasmine, bergamot, and stone fruit. Batch of 40 bags only — link in bio.",
tags: ["specialtycoffee", "pourover", "guji"],
likes: 634,
comments: [
{
id: "c3",
author: "Nina Okafor",
handle: "ninabrews",
avatar: "/img/gallery/g33.webp",
text: "Guji washed is my whole personality in autumn. Ordered two.",
time: "20h",
liked: false,
},
],
},
];
function formatCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}K`;
return `${n}`;
}
function HeartIcon({ filled }: { filled: boolean }) {
return (
<svg
viewBox="0 0 24 24"
className="h-6 w-6"
fill={filled ? "currentColor" : "none"}
stroke="currentColor"
strokeWidth={1.9}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M20.4 4.6a5.4 5.4 0 0 0-7.6 0L12 5.4l-.8-.8a5.4 5.4 0 1 0-7.6 7.6l.8.8L12 20.6l7.6-7.6.8-.8a5.4 5.4 0 0 0 0-7.6Z" />
</svg>
);
}
function CommentIcon() {
return (
<svg
viewBox="0 0 24 24"
className="h-6 w-6"
fill="none"
stroke="currentColor"
strokeWidth={1.9}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M21 11.5a8.4 8.4 0 0 1-11.9 7.6L3 21l1.9-6.1A8.4 8.4 0 1 1 21 11.5Z" />
</svg>
);
}
function ShareIcon({ done }: { done: boolean }) {
if (done) {
return (
<svg
viewBox="0 0 24 24"
className="h-6 w-6"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="m5 13 4 4L19 7" />
</svg>
);
}
return (
<svg
viewBox="0 0 24 24"
className="h-6 w-6"
fill="none"
stroke="currentColor"
strokeWidth={1.9}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M22 2 11 13" />
<path d="M22 2 15 22l-4-9-9-4 20-7Z" />
</svg>
);
}
function BookmarkIcon({ filled }: { filled: boolean }) {
return (
<svg
viewBox="0 0 24 24"
className="h-6 w-6"
fill={filled ? "currentColor" : "none"}
stroke="currentColor"
strokeWidth={1.9}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M19 21 12 16 5 21V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2Z" />
</svg>
);
}
function VerifiedBadge() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4 text-sky-500" aria-hidden="true">
<path
fill="currentColor"
d="M12 1.5l2.35 1.7 2.9-.15 1.05 2.7 2.55 1.4-.75 2.8.75 2.8-2.55 1.4-1.05 2.7-2.9-.15L12 22.5l-2.35-1.7-2.9.15-1.05-2.7L3.15 16.9l.75-2.8-.75-2.8L5.7 9.9l1.05-2.7 2.9.15L12 1.5Z"
/>
<path
fill="none"
stroke="#fff"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
d="m8.5 12 2.5 2.5 4.5-5"
/>
</svg>
);
}
function MoreIcon() {
return (
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor" aria-hidden="true">
<circle cx="5" cy="12" r="1.8" />
<circle cx="12" cy="12" r="1.8" />
<circle cx="19" cy="12" r="1.8" />
</svg>
);
}
function SendIcon() {
return (
<svg
viewBox="0 0 24 24"
className="h-5 w-5"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M22 2 11 13" />
<path d="M22 2 15 22l-4-9-9-4 20-7Z" />
</svg>
);
}
function SocialPostCard({ post }: { post: Post }) {
const reduce = useReducedMotion();
const uid = useId();
const commentsPanelId = `${uid}-comments`;
const commentInputRef = useRef<HTMLInputElement>(null);
const [liked, setLiked] = useState(false);
const [likes, setLikes] = useState(post.likes);
const [saved, setSaved] = useState(false);
const [following, setFollowing] = useState(false);
const [showComments, setShowComments] = useState(false);
const [copied, setCopied] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [burst, setBurst] = useState(0);
const [comments, setComments] = useState<Comment[]>(post.comments);
const [draft, setDraft] = useState("");
const [submitting, setSubmitting] = useState(false);
function applyLike(next: boolean) {
setLiked(next);
setLikes((c) => c + (next ? 1 : -1));
}
function toggleLike() {
applyLike(!liked);
}
function handleDoubleLike() {
if (!liked) applyLike(true);
setBurst((b) => b + 1);
}
function toggleCommentLike(id: string) {
setComments((list) =>
list.map((c) => (c.id === id ? { ...c, liked: !c.liked } : c)),
);
}
function openComments() {
setShowComments(true);
window.setTimeout(() => commentInputRef.current?.focus(), reduce ? 0 : 260);
}
async function handleShare() {
const url = `https://frame.social/${post.handle}/${post.id}`;
try {
await navigator.clipboard.writeText(url);
setCopied(true);
window.setTimeout(() => setCopied(false), 1800);
} catch {
setCopied(false);
}
}
function submitComment(e: FormEvent) {
e.preventDefault();
const text = draft.trim();
if (!text || submitting) return;
setSubmitting(true);
const entry: Comment = {
id: `${uid}-${Date.now()}`,
author: "You",
handle: "you",
avatar: "/img/gallery/g05.webp",
text,
time: "now",
liked: false,
};
window.setTimeout(
() => {
setComments((list) => [...list, entry]);
setDraft("");
setSubmitting(false);
commentInputRef.current?.focus();
},
reduce ? 0 : 320,
);
}
return (
<article className="w-full max-w-md overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm shadow-zinc-900/5 dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-black/40">
{/* Header */}
<header className="flex items-center gap-3 px-4 py-3">
<span className="relative inline-flex h-11 w-11 shrink-0 rounded-full bg-gradient-to-tr from-amber-400 via-rose-500 to-violet-600 p-[2px]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={post.avatar}
alt={`${post.author} avatar`}
loading="lazy"
draggable={false}
className="h-full w-full rounded-full border-2 border-white object-cover dark:border-zinc-900"
/>
</span>
<div className="min-w-0 flex-1 leading-tight">
<div className="flex items-center gap-1">
<span className="truncate text-sm font-semibold text-zinc-900 dark:text-zinc-50">
{post.author}
</span>
{post.verified && <VerifiedBadge />}
</div>
<p className="truncate text-xs text-zinc-500 dark:text-zinc-400">
{post.location} · {post.time}
</p>
</div>
<button
type="button"
onClick={() => setFollowing((f) => !f)}
aria-pressed={following}
className={`rounded-full px-3.5 py-1.5 text-xs font-semibold outline-none transition focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-900 ${
following
? "bg-zinc-100 text-zinc-700 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700"
: "bg-indigo-600 text-white hover:bg-indigo-500"
}`}
>
{following ? "Following" : "Follow"}
</button>
<div className="relative">
<button
type="button"
onClick={() => setMenuOpen((m) => !m)}
aria-label="Post options"
aria-haspopup="menu"
aria-expanded={menuOpen}
className="grid h-9 w-9 place-items-center rounded-full text-zinc-500 outline-none transition hover:bg-zinc-100 hover:text-zinc-800 focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100"
>
<MoreIcon />
</button>
<AnimatePresence>
{menuOpen && (
<motion.div
role="menu"
initial={reduce ? false : { opacity: 0, scale: 0.94, y: -4 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.94, y: -4 }}
transition={{ duration: 0.14 }}
className="absolute right-0 top-11 z-20 w-40 overflow-hidden rounded-2xl border border-zinc-200 bg-white py-1 text-sm shadow-xl shadow-zinc-900/10 dark:border-zinc-700 dark:bg-zinc-800 dark:shadow-black/50"
>
{["Report", "Not interested", "Copy link"].map((item) => (
<button
key={item}
type="button"
role="menuitem"
onClick={() => {
if (item === "Copy link") void handleShare();
setMenuOpen(false);
}}
className="block w-full px-4 py-2 text-left text-zinc-700 outline-none transition hover:bg-zinc-100 focus-visible:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-700 dark:focus-visible:bg-zinc-700"
>
{item}
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
</header>
{/* Media */}
<div
className="relative aspect-square w-full select-none bg-zinc-100 dark:bg-zinc-800"
onDoubleClick={handleDoubleLike}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={post.image}
alt={post.alt}
loading="lazy"
draggable={false}
className="h-full w-full object-cover"
/>
<AnimatePresence>
{burst > 0 && (
<motion.div
key={burst}
initial={{ opacity: 0, scale: 0.4 }}
animate={
reduce
? { opacity: 0 }
: { opacity: [0, 1, 1, 0], scale: [0.4, 1.15, 1, 1.3] }
}
transition={{ duration: reduce ? 0.01 : 0.9, times: [0, 0.2, 0.7, 1] }}
onAnimationComplete={() => setBurst(0)}
className="pointer-events-none absolute inset-0 grid place-items-center text-white drop-shadow-lg"
>
<HeartIcon filled />
</motion.div>
)}
</AnimatePresence>
</div>
{/* Action bar */}
<div className="flex items-center gap-1 px-3 pt-2.5">
<button
type="button"
onClick={toggleLike}
aria-pressed={liked}
aria-label={liked ? "Unlike post" : "Like post"}
className={`csp-tap grid h-10 w-10 place-items-center rounded-full outline-none transition-colors focus-visible:ring-2 focus-visible:ring-rose-500 ${
liked
? "text-rose-500"
: "text-zinc-700 hover:text-rose-500 dark:text-zinc-200 dark:hover:text-rose-400"
} ${liked && !reduce ? "csp-pop" : ""}`}
>
<HeartIcon filled={liked} />
</button>
<button
type="button"
onClick={() => (showComments ? setShowComments(false) : openComments())}
aria-expanded={showComments}
aria-controls={commentsPanelId}
aria-label="View comments"
className="grid h-10 w-10 place-items-center rounded-full text-zinc-700 outline-none transition-colors hover:text-indigo-600 focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-zinc-200 dark:hover:text-indigo-400"
>
<CommentIcon />
</button>
<button
type="button"
onClick={handleShare}
aria-label={copied ? "Link copied" : "Copy link to post"}
className={`grid h-10 w-10 place-items-center rounded-full outline-none transition-colors focus-visible:ring-2 focus-visible:ring-emerald-500 ${
copied
? "text-emerald-500"
: "text-zinc-700 hover:text-emerald-600 dark:text-zinc-200 dark:hover:text-emerald-400"
}`}
>
<ShareIcon done={copied} />
</button>
<button
type="button"
onClick={() => setSaved((s) => !s)}
aria-pressed={saved}
aria-label={saved ? "Remove from saved" : "Save post"}
className={`ml-auto grid h-10 w-10 place-items-center rounded-full outline-none transition-colors focus-visible:ring-2 focus-visible:ring-amber-500 ${
saved
? "text-amber-500"
: "text-zinc-700 hover:text-amber-500 dark:text-zinc-200 dark:hover:text-amber-400"
}`}
>
<BookmarkIcon filled={saved} />
</button>
</div>
{/* Body */}
<div className="px-4 pb-1">
<p
aria-live="polite"
className="text-sm font-semibold text-zinc-900 dark:text-zinc-50"
>
{formatCount(likes)} likes
</p>
<p className="mt-1.5 text-sm leading-relaxed text-zinc-800 dark:text-zinc-200">
<span className="mr-1.5 font-semibold text-zinc-900 dark:text-zinc-50">
{post.handle}
</span>
{post.caption}
</p>
<p className="mt-1 flex flex-wrap gap-x-1.5 text-sm font-medium text-indigo-600 dark:text-indigo-400">
{post.tags.map((t) => (
<span key={t}>#{t}</span>
))}
</p>
<button
type="button"
onClick={() => (showComments ? setShowComments(false) : openComments())}
aria-expanded={showComments}
aria-controls={commentsPanelId}
className="mt-2 rounded text-sm text-zinc-500 outline-none transition hover:text-zinc-700 focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-zinc-400 dark:hover:text-zinc-200"
>
{showComments
? "Hide comments"
: `View all ${comments.length} comments`}
</button>
</div>
{/* Comments */}
<AnimatePresence initial={false}>
{showComments && (
<motion.section
id={commentsPanelId}
key="comments"
initial={reduce ? false : { height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
transition={{ duration: 0.24, ease: [0.4, 0, 0.2, 1] }}
className="overflow-hidden"
>
<ul className="space-y-3 px-4 pb-2 pt-1">
{comments.map((c) => (
<li key={c.id} className="flex items-start gap-2.5">
<span className="mt-0.5 inline-flex h-8 w-8 shrink-0 overflow-hidden rounded-full">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={c.avatar}
alt={`${c.author} avatar`}
loading="lazy"
draggable={false}
className="h-full w-full object-cover"
/>
</span>
<div className="min-w-0 flex-1">
<p className="text-sm leading-snug text-zinc-800 dark:text-zinc-200">
<span className="mr-1.5 font-semibold text-zinc-900 dark:text-zinc-50">
{c.handle}
</span>
{c.text}
</p>
<p className="mt-0.5 text-xs text-zinc-400 dark:text-zinc-500">
{c.time}
</p>
</div>
<button
type="button"
onClick={() => toggleCommentLike(c.id)}
aria-pressed={c.liked}
aria-label={c.liked ? "Unlike comment" : "Like comment"}
className={`mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-full outline-none transition focus-visible:ring-2 focus-visible:ring-rose-500 ${
c.liked
? "text-rose-500"
: "text-zinc-400 hover:text-rose-500 dark:text-zinc-500 dark:hover:text-rose-400"
}`}
>
<svg
viewBox="0 0 24 24"
className="h-3.5 w-3.5"
fill={c.liked ? "currentColor" : "none"}
stroke="currentColor"
strokeWidth={2.2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M20.4 4.6a5.4 5.4 0 0 0-7.6 0L12 5.4l-.8-.8a5.4 5.4 0 1 0-7.6 7.6l.8.8L12 20.6l7.6-7.6.8-.8a5.4 5.4 0 0 0 0-7.6Z" />
</svg>
</button>
</li>
))}
</ul>
<form
onSubmit={submitComment}
className="flex items-center gap-2 border-t border-zinc-100 px-4 py-2.5 dark:border-zinc-800"
>
<input
ref={commentInputRef}
type="text"
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="Add a comment…"
aria-label="Add a comment"
className="min-w-0 flex-1 bg-transparent text-sm text-zinc-800 outline-none placeholder:text-zinc-400 dark:text-zinc-100 dark:placeholder:text-zinc-500"
/>
<button
type="submit"
disabled={!draft.trim() || submitting}
aria-label="Post comment"
className="grid h-8 w-8 place-items-center rounded-full text-indigo-600 outline-none transition hover:bg-indigo-50 focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:text-zinc-300 disabled:hover:bg-transparent dark:text-indigo-400 dark:hover:bg-indigo-950/50 dark:disabled:text-zinc-600"
>
{submitting ? (
<span className="csp-spin block h-4 w-4 rounded-full border-2 border-current border-t-transparent" />
) : (
<SendIcon />
)}
</button>
</form>
</motion.section>
)}
</AnimatePresence>
</article>
);
}
export default function CardSocialPost() {
return (
<section className="relative w-full bg-zinc-50 px-4 py-16 sm:px-6 sm:py-24 dark:bg-zinc-950">
<style>{`
@keyframes csp-pop-kf {
0% { transform: scale(1); }
35% { transform: scale(1.28); }
60% { transform: scale(0.92); }
100% { transform: scale(1); }
}
@keyframes csp-spin-kf {
to { transform: rotate(360deg); }
}
.csp-pop { animation: csp-pop-kf 0.42s ease-out; }
.csp-spin { animation: csp-spin-kf 0.7s linear infinite; }
.csp-tap { -webkit-tap-highlight-color: transparent; }
@media (prefers-reduced-motion: reduce) {
.csp-pop, .csp-spin { animation: none; }
}
`}</style>
<div className="mx-auto max-w-5xl">
<header className="mx-auto mb-12 max-w-2xl text-center">
<p className="text-sm font-semibold uppercase tracking-widest text-indigo-600 dark:text-indigo-400">
Frame · Social
</p>
<h2 className="mt-3 text-3xl font-bold tracking-tight text-zinc-900 sm:text-4xl dark:text-zinc-50">
Post cards that actually respond
</h2>
<p className="mt-4 text-base text-zinc-600 dark:text-zinc-400">
Double-tap the photo to like, expand the thread, drop a comment, and copy the
share link. Every control is a real, focusable button.
</p>
</header>
<div className="flex flex-wrap items-start justify-center gap-8">
{POSTS.map((post) => (
<SocialPostCard key={post.id} post={post} />
))}
</div>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
3D Tilt Cards
OriginalA responsive grid of cards that tilt in 3D toward your cursor with a light glare and lifted depth layers, powered by Framer Motion springs.

Spotlight Follow Cards
OriginalFeature cards where a soft radial spotlight and a glowing border chase the cursor on hover, with a staggered scroll reveal and an animated shimmer line.

3D Flip Cards
OriginalPricing-style cards that flip in 3D on hover or tap to reveal details on the back, keyboard accessible with a staggered entrance animation.

Animated Gradient Border Cards
OriginalCards wrapped in a live conic gradient border that rotates and speeds up on hover, with a shimmer sweep and a scrolling tag marquee.

Glow Effect Card
primitivesA reusable glow effect card for React and Tailwind, with hover and motion.

Neon Gradient Card
gradient-card.tsxA reusable neon gradient card for React and Tailwind, with hover and motion.

Spotlight Magic Card
card.tsxA reusable spotlight magic card for React and Tailwind, with hover and motion.

Tilt Spotlight Card
primitivesA reusable tilt spotlight card for React and Tailwind, with hover and motion.

Profile Card
OriginalA user profile card with avatar, stats and a follow toggle.

Product Card
OriginalA product card with image, price and add-to-cart feedback.

Pricing Single Card
OriginalA single pricing plan card with feature list and CTA.

Stat Card
OriginalA metric card with value, trend arrow and a small sparkline.

