Web InnoventixFreeCode

Social Post Card

Original · free

A social media post card with image, likes and comments.

byWeb InnoventixReact + Tailwind
cardsocialpostcards
Open

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
card-social-post.tsx
"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

motion

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 quote

Similar components

Browse all →