# Social posts

> Four feed posts — text, photo grid, poll and link preview — with optimistic toggles, rolling counts, a shared-layout photo viewer and results that count up.

- Collection: Examples
- Tier: free
- Docs: https://geturui.com/docs/social-posts
- Install: `npx shadcn@latest add @geturui/social-posts`
- Dependencies: motion, lucide-react

## Variants

### Text post

Text post with optimistic like, repost and bookmark: icons pop, the like bursts and counts roll.

Install: `npx shadcn@latest add @geturui/social-posts-text-post`

```tsx
import { TextPost } from "@/components/examples/social-posts/text-post";

export function Post() {
  return <TextPost author="Maya Kerrigan" body={["We cut our default duration to 180ms."]} likes={1840} onChange={(s) => save(s)} />;
}
```

| Prop | Type | Default | Description |
|---|---|---|---|
| author / handle / time | `string` | — | Header content. |
| verified | `boolean` | true | Show the verified badge. |
| body | `string[]` | — | Paragraphs of the post. |
| replies / reposts / likes / views | `number` | — | Counts from other people. |
| defaultLiked / defaultReposted / defaultBookmarked | `boolean` | false | Initial viewer state. |
| onReply / onChange | `() => void / (state) => void` | — | Reply handler and toggle state callback. |
| className | `string` | — | Extra classes on the root element. |

### Photo grid

Photo post whose drawn grid tiles expand into a viewer with a shared-layout morph, arrow paging and Escape to close.

Install: `npx shadcn@latest add @geturui/social-posts-image-grid`

```tsx
import { ImageGridPost } from "@/components/examples/social-posts/image-grid";

export function Photos() {
  return <ImageGridPost author="Lena Ortiz" caption="Four mornings, four places." onLikeChange={(l) => like(l)} />;
}
```

| Prop | Type | Default | Description |
|---|---|---|---|
| author / place / time / caption | `string` | — | Post content. |
| images | `PostImage[]` | — | { scene, alt } per tile, up to four; swap Scene for your <img> in real use. |
| likes / comments | `number` | — | Counts. |
| onLikeChange | `(liked: boolean) => void` | — | Like toggle handler. |
| className | `string` | — | Extra classes on the root element. |

### Poll

Poll post where voting grows the result bars and counts percentages up, with your choice marked and undo.

Install: `npx shadcn@latest add @geturui/social-posts-poll`

```tsx
import { PollPost } from "@/components/examples/social-posts/poll";

export function Vote() {
  return <PollPost question="Which default should ship?" options={options} remaining="2 days left" onVote={(id) => vote(id)} />;
}
```

| Prop | Type | Default | Description |
|---|---|---|---|
| author / handle / time | `string` | — | Header content. |
| question | `string` | — | Poll question. |
| options | `PollOption[]` | — | { id, label, votes } per option. |
| remaining | `string` | "2 days left" | Time-left label. |
| defaultVote / onVote | `string | null / (id: string | null) => void` | — | Existing vote and change handler (null on undo). |
| className | `string` | — | Extra classes on the root element. |

### Link preview

Post with an expandable body and a rich link preview whose drawn image drifts on hover, plus like and copy link.

Install: `npx shadcn@latest add @geturui/social-posts-link-preview`

```tsx
import { LinkPreviewPost } from "@/components/examples/social-posts/link-preview";

export function Share() {
  return <LinkPreviewPost text="We gave every squad a motion budget." link={{ url, domain: "geturui.com", title, description }} />;
}
```

| Prop | Type | Default | Description |
|---|---|---|---|
| author / role / time | `string` | — | Header content. |
| text / more | `string` | — | Opening text and the part revealed by Show more. |
| link | `LinkPreview` | — | { url, domain, title, description, kicker? } for the preview. |
| likes / comments | `number` | — | Counts. |
| onLikeChange | `(liked: boolean) => void` | — | Like toggle handler. |
| className | `string` | — | Extra classes on the root element. |

## Accessibility

- Like, repost and bookmark are toggle buttons with aria-pressed and counts in their labels.
- The photo viewer is a labelled dialog: focus moves to Close, arrows page, Escape closes and focus returns to the tile.
- Poll results are announced in a live region; bursts, morphs and count-ups are reduced to fades.

## Source

### components/examples/social-posts/text-post.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { BadgeCheck, BarChart2, Bookmark, Heart, MessageCircle, MoreHorizontal, Repeat2, type LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";

export interface TextPostProps {
  author?: string;
  handle?: string;
  verified?: boolean;
  /** Relative time label, e.g. "2h". */
  time?: string;
  /** Paragraphs of the post. */
  body?: string[];
  replies?: number;
  reposts?: number;
  likes?: number;
  views?: number;
  defaultLiked?: boolean;
  defaultReposted?: boolean;
  defaultBookmarked?: boolean;
  onReply?: () => void;
  onChange?: (state: { liked: boolean; reposted: boolean; bookmarked: boolean }) => void;
  className?: string;
}

const compact = (n: number) => new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }).format(n);
const spring = { type: "spring", stiffness: 480, damping: 30 } as const;
const BURST = Array.from({ length: 6 }, (_, i) => ({ x: Math.cos((i / 6) * Math.PI * 2) * 16, y: Math.sin((i / 6) * Math.PI * 2) * 16 }));

function Rolling({ value, up, reduce }: { value: number; up: boolean; reduce: boolean }) {
  return (
    <span className="relative inline-flex overflow-hidden tabular-nums">
      <AnimatePresence mode="popLayout" initial={false}>
        <motion.span key={value} initial={reduce ? { opacity: 0 } : { y: up ? 12 : -12, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={reduce ? { opacity: 0 } : { y: up ? -12 : 12, opacity: 0 }} transition={spring}>
          {compact(value)}
        </motion.span>
      </AnimatePresence>
    </span>
  );
}

function Pop({ on, Icon, fill, reduce }: { on: boolean; Icon: LucideIcon; fill?: boolean; reduce: boolean }) {
  return (
    <motion.span initial={reduce || !on ? false : { scale: 0.4 }} animate={{ scale: 1 }} transition={{ type: "spring", stiffness: 600, damping: 13 }} className="grid">
      <Icon aria-hidden className={cn("size-[18px]", on && fill && "fill-current")} strokeWidth={on && !fill ? 2.6 : 2} />
    </motion.span>
  );
}

/** Text post with optimistic like, repost and bookmark toggles: icons pop, the like bursts and every count rolls to its new value. */
export function TextPost({
  author = "Maya Kerrigan",
  handle = "@mayamakes",
  verified = true,
  time = "2h",
  body = [
    "Hot take after a year of shipping motion in production: most interfaces don't need more animation, they need shorter animation.",
    "We cut our default duration from 400ms to 180ms and support tickets about the app feeling slow dropped by a third.",
  ],
  replies = 48,
  reposts = 212,
  likes = 1840,
  views = 48200,
  defaultLiked = false,
  defaultReposted = false,
  defaultBookmarked = false,
  onReply,
  onChange,
  className,
}: TextPostProps) {
  const reduce = useReducedMotion() ?? false;
  const [state, setState] = React.useState({ liked: defaultLiked, reposted: defaultReposted, bookmarked: defaultBookmarked });
  const [burst, setBurst] = React.useState(0);
  const id = React.useId();

  function flip(key: keyof typeof state) {
    const next = { ...state, [key]: !state[key] };
    setState(next);
    onChange?.(next);
    if (key === "liked" && next.liked && !reduce) setBurst((b) => b + 1);
  }

  const likeCount = likes + (state.liked ? 1 : 0) - (defaultLiked ? 1 : 0);
  const repostCount = reposts + (state.reposted ? 1 : 0) - (defaultReposted ? 1 : 0);
  const action = "group/a inline-flex h-8 items-center gap-1.5 rounded-full px-2 text-[12.5px] outline-none transition-colors focus-visible:shadow-[var(--focus-ring)]";

  return (
    <article aria-labelledby={`${id}-a`} className={cn("w-full max-w-[520px] rounded-xl border border-border bg-surface-raised p-4 shadow-sm", className)}>
      <header className="flex items-start gap-3">
        <span aria-hidden className="grid size-10 shrink-0 place-items-center rounded-full bg-ink font-display text-[13px] font-semibold text-bg">
          {author.split(" ").map((p) => p[0]).join("").slice(0, 2)}
        </span>
        <div className="min-w-0 flex-1">
          <p id={`${id}-a`} className="flex items-center gap-1 text-[14px] font-semibold text-ink">
            <span className="truncate">{author}</span>
            {verified && <BadgeCheck aria-label="Verified" className="size-4 shrink-0 text-volt-ink" />}
          </p>
          <p className="text-[12.5px] text-ink-subtle">
            {handle} · {time}
          </p>
        </div>
        <button type="button" aria-label="More options" className="grid size-8 place-items-center rounded-full text-ink-subtle outline-none transition-colors hover:bg-surface-hover hover:text-ink focus-visible:shadow-[var(--focus-ring)]">
          <MoreHorizontal aria-hidden className="size-4" />
        </button>
      </header>

      <div className="mt-3 space-y-2.5 text-[14.5px] leading-[1.55] text-ink">
        {body.map((p, i) => (
          <p key={i}>{p}</p>
        ))}
      </div>

      <div className="-mx-2 mt-3 flex items-center justify-between">
        <button type="button" onClick={onReply} aria-label={`Reply, ${replies} replies`} className={cn(action, "text-ink-subtle hover:bg-surface-hover hover:text-ink")}>
          <MessageCircle aria-hidden className="size-[18px]" />
          {compact(replies)}
        </button>
        <button type="button" aria-pressed={state.reposted} aria-label={`Repost, ${repostCount} reposts`} onClick={() => flip("reposted")} className={cn(action, state.reposted ? "font-semibold text-ink" : "text-ink-subtle", "hover:bg-surface-hover hover:text-ink")}>
          <Pop key={`r${state.reposted}`} on={state.reposted} Icon={Repeat2} reduce={reduce} />
          <Rolling value={repostCount} up={state.reposted} reduce={reduce} />
        </button>
        <button type="button" aria-pressed={state.liked} aria-label={`Like, ${likeCount} likes`} onClick={() => flip("liked")} className={cn(action, state.liked ? "font-semibold text-volt-ink" : "text-ink-subtle", "relative hover:bg-surface-hover")}>
          <span className="relative grid">
            <AnimatePresence>
              {burst > 0 &&
                BURST.map((b, i) => (
                  <motion.span
                    key={`${burst}-${i}`}
                    aria-hidden
                    initial={{ x: 0, y: 0, opacity: 1, scale: 1 }}
                    animate={{ x: b.x, y: b.y, opacity: 0, scale: 0.3 }}
                    transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
                    className="absolute left-1/2 top-1/2 -ml-0.5 -mt-0.5 size-1 rounded-full bg-volt-ink"
                  />
                ))}
            </AnimatePresence>
            <Pop key={`l${state.liked}`} on={state.liked} Icon={Heart} fill reduce={reduce} />
          </span>
          <Rolling value={likeCount} up={state.liked} reduce={reduce} />
        </button>
        <span className="inline-flex items-center gap-1.5 px-2 text-[12.5px] text-ink-subtle">
          <BarChart2 aria-hidden className="size-[18px]" />
          <span className="sr-only">Views</span>
          {compact(views)}
        </span>
        <button type="button" aria-pressed={state.bookmarked} aria-label={state.bookmarked ? "Remove bookmark" : "Bookmark"} onClick={() => flip("bookmarked")} className={cn(action, state.bookmarked ? "text-ink" : "text-ink-subtle", "hover:bg-surface-hover hover:text-ink")}>
          <Pop key={`b${state.bookmarked}`} on={state.bookmarked} Icon={Bookmark} fill reduce={reduce} />
        </button>
      </div>
    </article>
  );
}

```

### components/examples/social-posts/image-grid.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ChevronLeft, ChevronRight, Heart, MessageCircle, X } from "lucide-react";
import { cn } from "@/lib/utils";

export type SceneKind = "dunes" | "harbour" | "ridge" | "market";

export interface PostImage {
  scene: SceneKind;
  alt: string;
}

export interface ImageGridPostProps {
  author?: string;
  place?: string;
  time?: string;
  caption?: string;
  images?: PostImage[];
  likes?: number;
  comments?: number;
  onLikeChange?: (liked: boolean) => void;
  className?: string;
}

const DEFAULT_IMAGES: PostImage[] = [
  { scene: "dunes", alt: "Sand dunes at first light under a pale sun" },
  { scene: "harbour", alt: "Fishing boats in a harbour wrapped in fog" },
  { scene: "ridge", alt: "Pine ridge in layers of blue haze" },
  { scene: "market", alt: "Night market lanterns strung over a street" },
];

function Scene({ kind }: { kind: SceneKind }) {
  return (
    <svg viewBox="0 0 160 120" preserveAspectRatio="xMidYMid slice" className="size-full" aria-hidden>
      {kind === "dunes" && (
        <>
          <rect width="160" height="120" fill="#f1d9b5" />
          <circle cx="112" cy="40" r="16" fill="#fff4dd" />
          <path d="M0 78 Q40 58 84 76 T160 70 V120 H0 Z" fill="#d9a46a" />
          <path d="M0 96 Q50 80 100 98 T160 92 V120 H0 Z" fill="#b97a45" />
        </>
      )}
      {kind === "harbour" && (
        <>
          <rect width="160" height="120" fill="#c9d3d8" />
          <rect y="74" width="160" height="46" fill="#8fa3ad" />
          <path d="M30 74 L40 52 L40 74 Z M90 74 L102 44 L102 74 Z" fill="#4c5f69" />
          <path d="M22 74 H54 L50 82 H26 Z M82 74 H118 L113 83 H87 Z" fill="#33434b" />
          <rect y="60" width="160" height="10" fill="#ffffff" opacity="0.35" />
        </>
      )}
      {kind === "ridge" && (
        <>
          <rect width="160" height="120" fill="#dfe8ee" />
          <path d="M0 70 L30 44 L56 62 L88 34 L124 60 L160 40 V120 H0 Z" fill="#9db3c4" />
          <path d="M0 88 L24 70 L52 84 L80 64 L112 86 L140 70 L160 80 V120 H0 Z" fill="#5d7a8f" />
          <path d="M0 104 L36 90 L70 104 L110 92 L160 102 V120 H0 Z" fill="#2f4656" />
        </>
      )}
      {kind === "market" && (
        <>
          <rect width="160" height="120" fill="#1f1d2b" />
          <path d="M0 26 Q80 50 160 22" fill="none" stroke="#3a3650" strokeWidth="1.5" />
          {[16, 40, 64, 88, 112, 136].map((x, i) => (
            <circle key={x} cx={x} cy={30 + Math.sin(i) * 3 + (i === 2 || i === 3 ? 9 : 4)} r="5" fill={i % 2 ? "#ffb35c" : "#ff7a45"} />
          ))}
          <rect x="10" y="78" width="44" height="42" fill="#2c2940" />
          <rect x="104" y="72" width="48" height="48" fill="#2c2940" />
          <rect x="16" y="84" width="32" height="8" fill="#ffb35c" opacity="0.6" />
        </>
      )}
    </svg>
  );
}

/** Photo post with a drawn image grid: a tile expands into a viewer with a shared-layout morph, arrow-key paging and Escape to close. */
export function ImageGridPost({
  author = "Lena Ortiz",
  place = "Field notes, week 38",
  time = "5h",
  caption = "Four mornings, four places, one camera. The fog in the harbour lifted about ten minutes after this.",
  images = DEFAULT_IMAGES,
  likes = 326,
  comments = 18,
  onLikeChange,
  className,
}: ImageGridPostProps) {
  const reduce = useReducedMotion() ?? false;
  const [view, setView] = React.useState<{ origin: number; index: number } | null>(null);
  const open = view?.index ?? null;
  const [liked, setLiked] = React.useState(false);
  const tiles = React.useRef<(HTMLButtonElement | null)[]>([]);
  const closeBtn = React.useRef<HTMLButtonElement>(null);
  const id = React.useId();
  const shown = images.slice(0, 4);

  const isOpen = view !== null;
  React.useEffect(() => {
    if (isOpen) closeBtn.current?.focus();
  }, [isOpen]);

  function close() {
    const i = view?.index;
    setView(null);
    if (i !== undefined) requestAnimationFrame(() => tiles.current[i]?.focus());
  }

  const page = (d: number) => setView((v) => (v ? { ...v, index: (v.index + d + shown.length) % shown.length } : v));
  const navBtn = "grid size-9 place-items-center rounded-full bg-glass text-ink shadow-sm outline-none backdrop-blur focus-visible:shadow-[var(--focus-ring)]";
  const layout = (i: number) => (reduce ? undefined : `${id}-img-${i}`);

  return (
    <article aria-label={`Photos from ${author}`} className={cn("w-full max-w-[520px] rounded-xl border border-border bg-surface-raised p-4 shadow-sm", className)}>
      <header className="flex items-center gap-3">
        <span aria-hidden className="grid size-10 place-items-center rounded-full bg-ink font-display text-[13px] font-semibold text-bg">
          {author.split(" ").map((p) => p[0]).join("")}
        </span>
        <div className="min-w-0">
          <p className="text-[14px] font-semibold text-ink">{author}</p>
          <p className="text-[12.5px] text-ink-subtle">
            {place} · {time}
          </p>
        </div>
      </header>
      <p className="mt-3 text-[14px] leading-[1.5] text-ink">{caption}</p>

      <div
        className="relative mt-3 aspect-[4/3] overflow-hidden rounded-lg"
        onKeyDown={(e) => {
          if (open === null) return;
          if (e.key === "Escape") close();
          if (e.key === "ArrowRight") page(1);
          if (e.key === "ArrowLeft") page(-1);
        }}
      >
        <div className={cn("grid size-full gap-1", shown.length > 1 && "grid-cols-2", shown.length > 2 && "grid-rows-2")}>
          {shown.map((img, i) => (
            <button
              key={i}
              ref={(el) => {
                tiles.current[i] = el;
              }}
              type="button"
              aria-label={`Open photo ${i + 1} of ${shown.length}: ${img.alt}`}
              onClick={() => setView({ origin: i, index: i })}
              className={cn("group relative overflow-hidden outline-none focus-visible:shadow-[inset_0_0_0_3px_var(--volt-ink)]", shown.length === 3 && i === 0 && "row-span-2")}
            >
              {view?.origin !== i && (
                <motion.div layoutId={layout(i)} transition={{ type: "spring", stiffness: 300, damping: 32 }} className="absolute inset-0 transition-transform duration-500 group-hover:scale-105">
                  <Scene kind={img.scene} />
                </motion.div>
              )}
            </button>
          ))}
        </div>

        <AnimatePresence>
          {view !== null && open !== null && (
            <motion.div key="viewer" role="dialog" aria-label={shown[open].alt} exit={view.origin === open ? undefined : { opacity: 0 }} className="absolute inset-0 z-10">
              <motion.div layoutId={view.origin === open ? layout(view.origin) : undefined} transition={{ type: "spring", stiffness: 300, damping: 32 }} className="absolute inset-0">
                <AnimatePresence mode="popLayout" initial={false}>
                  <motion.div key={open} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute inset-0">
                    <Scene kind={shown[open].scene} />
                  </motion.div>
                </AnimatePresence>
              </motion.div>
              <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1, transition: { delay: 0.15 } }} exit={{ opacity: 0, transition: { duration: 0.1 } }} className="absolute inset-0">
                <button ref={closeBtn} type="button" aria-label="Close photo" onClick={close} className={cn(navBtn, "absolute right-3 top-3")}>
                  <X aria-hidden className="size-4" />
                </button>
                <button type="button" aria-label="Previous photo" onClick={() => page(-1)} className={cn(navBtn, "absolute left-3 top-1/2 -translate-y-1/2")}>
                  <ChevronLeft aria-hidden className="size-4" />
                </button>
                <button type="button" aria-label="Next photo" onClick={() => page(1)} className={cn(navBtn, "absolute right-3 top-1/2 -translate-y-1/2")}>
                  <ChevronRight aria-hidden className="size-4" />
                </button>
                <p aria-live="polite" className="absolute bottom-3 left-3 right-3 truncate rounded-full bg-glass px-3 py-1.5 text-[12px] text-ink backdrop-blur">
                  {open + 1} of {shown.length} · {shown[open].alt}
                </p>
              </motion.div>
            </motion.div>
          )}
        </AnimatePresence>
      </div>

      <div className="-mx-2 mt-2 flex items-center gap-1">
        <motion.button
          type="button"
          aria-pressed={liked}
          aria-label={liked ? "Unlike" : "Like"}
          onClick={() => {
            setLiked(!liked);
            onLikeChange?.(!liked);
          }}
          whileTap={reduce ? undefined : { scale: 0.85 }}
          className={cn("inline-flex h-8 items-center gap-1.5 rounded-full px-2 text-[12.5px] outline-none transition-colors hover:bg-surface-hover focus-visible:shadow-[var(--focus-ring)]", liked ? "font-semibold text-volt-ink" : "text-ink-subtle")}
        >
          <Heart aria-hidden className={cn("size-[18px]", liked && "fill-current")} />
          {(likes + (liked ? 1 : 0)).toLocaleString("en-US")}
        </motion.button>
        <span className="inline-flex h-8 items-center gap-1.5 px-2 text-[12.5px] text-ink-subtle">
          <MessageCircle aria-hidden className="size-[18px]" />
          {comments}
          <span className="sr-only">comments</span>
        </span>
      </div>
    </article>
  );
}

```

### components/examples/social-posts/poll.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion, useSpring, useTransform } from "motion/react";
import { CheckCircle2, Clock } from "lucide-react";
import { cn } from "@/lib/utils";

export interface PollOption {
  id: string;
  label: string;
  votes: number;
}

export interface PollPostProps {
  author?: string;
  handle?: string;
  time?: string;
  question?: string;
  options?: PollOption[];
  /** Label for the time left, e.g. "2 days left". */
  remaining?: string;
  /** Option id the viewer already voted for. */
  defaultVote?: string | null;
  onVote?: (optionId: string | null) => void;
  className?: string;
}

const DEFAULT_OPTIONS: PollOption[] = [
  { id: "o1", label: "Spring, stiffness 400", votes: 412 },
  { id: "o2", label: "Ease out, 200ms", votes: 268 },
  { id: "o3", label: "Ease in-out, 300ms", votes: 157 },
  { id: "o4", label: "No motion by default", votes: 89 },
];

function Percent({ value, reduce }: { value: number; reduce: boolean }) {
  const mv = useSpring(reduce ? value : 0, { stiffness: 120, damping: 22 });
  const text = useTransform(mv, (v) => `${Math.round(v)}%`);
  React.useEffect(() => {
    if (reduce) mv.jump(value);
    else mv.set(value);
  }, [mv, value, reduce]);
  return <motion.span className="tabular-nums">{text}</motion.span>;
}

/** Poll post: pick an option and the results bars grow in with percentages counting up, your choice marked, with undo while the poll is open. */
export function PollPost({
  author = "Jonah Reyes",
  handle = "@jonahbuilds",
  time = "1h",
  question = "Which motion default should ship in our design system v2?",
  options = DEFAULT_OPTIONS,
  remaining = "2 days left",
  defaultVote = null,
  onVote,
  className,
}: PollPostProps) {
  const reduce = useReducedMotion() ?? false;
  const [vote, setVote] = React.useState<string | null>(defaultVote);
  const id = React.useId();

  const counts = options.map((o) => o.votes + (vote === o.id && defaultVote !== o.id ? 1 : 0) - (defaultVote === o.id && vote !== o.id ? 1 : 0));
  const total = counts.reduce((a, b) => a + b, 0);
  const pct = counts.map((c) => (total ? (c / total) * 100 : 0));
  const lead = Math.max(...counts);

  function choose(next: string | null) {
    setVote(next);
    onVote?.(next);
  }

  return (
    <article aria-labelledby={`${id}-q`} className={cn("w-full max-w-[480px] rounded-xl border border-border bg-surface-raised p-4 shadow-sm", className)}>
      <header className="flex items-center gap-3">
        <span aria-hidden className="grid size-10 place-items-center rounded-full bg-ink font-display text-[13px] font-semibold text-bg">
          {author.split(" ").map((p) => p[0]).join("")}
        </span>
        <p className="min-w-0 text-[14px] font-semibold text-ink">
          {author}
          <span className="block text-[12.5px] font-normal text-ink-subtle">
            {handle} · {time}
          </span>
        </p>
      </header>
      <h3 id={`${id}-q`} className="mt-3 text-[15px] font-medium leading-[1.45] text-ink">
        {question}
      </h3>

      <AnimatePresence mode="wait" initial={false}>
        {vote === null ? (
          <motion.ul key="vote" exit={{ opacity: 0, filter: "blur(6px)" }} transition={{ duration: 0.15 }} className="mt-3 flex flex-col gap-2">
            {options.map((o) => (
              <li key={o.id}>
                <motion.button
                  type="button"
                  onClick={() => choose(o.id)}
                  whileTap={reduce ? undefined : { scale: 0.98 }}
                  className="h-10 w-full rounded-full border border-border-strong px-4 text-left text-[13.5px] font-medium text-ink outline-none transition-colors hover:bg-surface-hover focus-visible:shadow-[var(--focus-ring)]"
                >
                  {o.label}
                </motion.button>
              </li>
            ))}
          </motion.ul>
        ) : (
          <motion.ul key="results" aria-label="Poll results" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="mt-3 flex flex-col gap-2">
            {options.map((o, i) => {
              const mine = vote === o.id;
              const top = counts[i] === lead;
              return (
                <li key={o.id} className="relative flex h-10 items-center overflow-hidden rounded-sm px-3.5">
                  <span aria-hidden className="absolute inset-0 bg-surface" />
                  <motion.span
                    aria-hidden
                    initial={{ scaleX: reduce ? pct[i] / 100 : 0 }}
                    animate={{ scaleX: pct[i] / 100 }}
                    transition={{ type: "spring", stiffness: 120, damping: 22, delay: reduce ? 0 : i * 0.05 }}
                    className={cn("absolute inset-y-0 left-0 w-full origin-left", mine ? "bg-volt/30" : top ? "bg-ink/15" : "bg-ink/[0.07]")}
                  />
                  <span className={cn("relative flex min-w-0 flex-1 items-center gap-1.5 text-[13.5px]", top ? "font-semibold text-ink" : "text-ink-muted")}>
                    <span className="truncate">{o.label}</span>
                    {mine && (
                      <>
                        <CheckCircle2 aria-hidden className="size-4 shrink-0 text-volt-ink" />
                        <span className="sr-only">Your vote</span>
                      </>
                    )}
                  </span>
                  <span className={cn("relative text-[13px]", top ? "font-semibold text-ink" : "text-ink-muted")}>
                    <Percent value={pct[i]} reduce={reduce} />
                  </span>
                </li>
              );
            })}
          </motion.ul>
        )}
      </AnimatePresence>

      <div className="mt-3 flex items-center gap-2 text-[12.5px] text-ink-subtle">
        <span className="tabular-nums">{total.toLocaleString("en-US")} votes</span>
        <span aria-hidden>·</span>
        <span className="inline-flex items-center gap-1">
          <Clock aria-hidden className="size-3.5" />
          {remaining}
        </span>
        {vote !== null && (
          <button type="button" onClick={() => choose(null)} className="ml-auto rounded-xs px-1 font-medium text-ink outline-none underline-offset-2 hover:underline focus-visible:shadow-[var(--focus-ring)]">
            Undo vote
          </button>
        )}
      </div>
      <p aria-live="polite" className="sr-only">
        {vote ? `Voted for ${options.find((o) => o.id === vote)?.label}. ${options.map((o, i) => `${o.label} ${Math.round(pct[i])}%`).join(", ")}` : ""}
      </p>
    </article>
  );
}

```

### components/examples/social-posts/link-preview.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowUpRight, Check, Globe, Heart, Link2, MessageCircle } from "lucide-react";
import { cn } from "@/lib/utils";

export interface LinkPreview {
  url: string;
  domain: string;
  title: string;
  description: string;
  /** Short label drawn on the preview image. */
  kicker?: string;
}

export interface LinkPreviewPostProps {
  author?: string;
  role?: string;
  time?: string;
  /** Always-visible opening of the post. */
  text?: string;
  /** Revealed by "Show more". */
  more?: string;
  link?: LinkPreview;
  likes?: number;
  comments?: number;
  onLikeChange?: (liked: boolean) => void;
  className?: string;
}

const DEFAULT_LINK: LinkPreview = {
  url: "https://geturui.com/journal/motion-budget",
  domain: "geturui.com",
  title: "A motion budget for product teams",
  description: "How to decide what moves, for how long, and what stays still. With a one-page worksheet.",
  kicker: "Journal",
};

/** Post with an expandable body and a rich link preview whose drawn image drifts on hover, plus like and copy-link actions. */
export function LinkPreviewPost({
  author = "Priya Nair",
  role = "Head of design, Kestrel",
  time = "3h",
  text = "We gave every squad a motion budget this quarter: three signature moments per flow, everything else under 200ms.",
  more = "The result was calmer screens and faster reviews. Nobody argued about easing curves anymore because the budget made the trade-offs visible. Write-up below, worksheet included.",
  link = DEFAULT_LINK,
  likes = 94,
  comments = 12,
  onLikeChange,
  className,
}: LinkPreviewPostProps) {
  const reduce = useReducedMotion() ?? false;
  const [expanded, setExpanded] = React.useState(false);
  const [liked, setLiked] = React.useState(false);
  const [copied, setCopied] = React.useState(false);
  const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
  const moreId = React.useId();

  React.useEffect(() => () => {
    if (timer.current) clearTimeout(timer.current);
  }, []);

  async function copy() {
    try {
      await navigator.clipboard?.writeText(link.url);
    } catch {
      // Clipboard may be blocked; still confirm so the action feels finished.
    }
    setCopied(true);
    if (timer.current) clearTimeout(timer.current);
    timer.current = setTimeout(() => setCopied(false), 1800);
  }

  const action = "inline-flex h-8 items-center gap-1.5 rounded-full px-2 text-[12.5px] outline-none transition-colors hover:bg-surface-hover focus-visible:shadow-[var(--focus-ring)]";

  return (
    <article aria-label={`Post by ${author}`} className={cn("w-full max-w-[520px] rounded-xl border border-border bg-surface-raised p-4 shadow-sm", className)}>
      <header className="flex items-center gap-3">
        <span aria-hidden className="grid size-10 place-items-center rounded-full bg-ink font-display text-[13px] font-semibold text-bg">
          {author.split(" ").map((p) => p[0]).join("")}
        </span>
        <div className="min-w-0">
          <p className="text-[14px] font-semibold text-ink">{author}</p>
          <p className="truncate text-[12.5px] text-ink-subtle">
            {role} · {time}
          </p>
        </div>
      </header>

      <div className="mt-3 text-[14px] leading-[1.55] text-ink">
        <p>
          {text}
          {!expanded && more && (
            <>
              {" "}
              <button type="button" aria-expanded={false} aria-controls={moreId} onClick={() => setExpanded(true)} className="rounded-xs font-medium text-ink-muted outline-none underline-offset-2 hover:text-ink hover:underline focus-visible:shadow-[var(--focus-ring)]">
                Show more
              </button>
            </>
          )}
        </p>
        <AnimatePresence initial={false}>
          {expanded && (
            <motion.p
              id={moreId}
              initial={{ height: 0, opacity: 0, filter: "blur(4px)" }}
              animate={{ height: "auto", opacity: 1, filter: "blur(0px)" }}
              transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 260, damping: 30 }}
              className="overflow-hidden"
            >
              <span className="block pt-2">{more}</span>
            </motion.p>
          )}
        </AnimatePresence>
      </div>

      <a
        href={link.url}
        target="_blank"
        rel="noreferrer"
        className="group mt-3 block overflow-hidden rounded-lg border border-border bg-surface outline-none transition-colors hover:border-border-strong focus-visible:shadow-[var(--focus-ring)]"
      >
        <div aria-hidden className="relative h-36 overflow-hidden bg-surface-sunken">
          <div className="absolute inset-0 transition-transform duration-700 ease-spring-soft group-hover:scale-[1.04]">
            <span className="absolute -right-6 -top-10 size-40 rounded-full border-[16px] border-ink/[0.07]" />
            <span className="absolute bottom-5 right-10 h-14 w-24 rounded-md bg-ink/10" />
            <span className="absolute bottom-11 right-24 size-9 rounded-full bg-volt transition-[translate] duration-700 ease-spring group-hover:-translate-x-3 group-hover:-translate-y-1" />
          </div>
          <span className="absolute left-4 top-4 font-mono text-[10.5px] uppercase tracking-[0.14em] text-ink-subtle">{link.kicker}</span>
          <span className="absolute bottom-4 left-4 max-w-[55%] font-display text-[19px] font-semibold leading-[1.1] tracking-[-0.4px] text-ink">{link.title}</span>
        </div>
        <div className="flex items-start gap-3 px-3.5 py-3">
          <div className="min-w-0 flex-1">
            <p className="flex items-center gap-1 text-[11.5px] text-ink-subtle">
              <Globe aria-hidden className="size-3" />
              {link.domain}
            </p>
            <p className="mt-0.5 truncate text-[13.5px] font-semibold text-ink">{link.title}</p>
            <p className="line-clamp-2 text-[12.5px] leading-[1.45] text-ink-muted">{link.description}</p>
          </div>
          <ArrowUpRight aria-hidden className="mt-1 size-4 shrink-0 text-ink-subtle transition-[translate,color] duration-300 group-hover:-translate-y-0.5 group-hover:translate-x-0.5 group-hover:text-ink" />
        </div>
      </a>

      <div className="-mx-2 mt-2 flex items-center gap-1">
        <motion.button
          type="button"
          aria-pressed={liked}
          aria-label={liked ? "Unlike" : "Like"}
          onClick={() => {
            setLiked(!liked);
            onLikeChange?.(!liked);
          }}
          whileTap={reduce ? undefined : { scale: 0.85 }}
          className={cn(action, liked ? "font-semibold text-volt-ink" : "text-ink-subtle")}
        >
          <Heart aria-hidden className={cn("size-[18px]", liked && "fill-current")} />
          {likes + (liked ? 1 : 0)}
        </motion.button>
        <span className="inline-flex h-8 items-center gap-1.5 px-2 text-[12.5px] text-ink-subtle">
          <MessageCircle aria-hidden className="size-[18px]" />
          {comments}
          <span className="sr-only">comments</span>
        </span>
        <motion.button type="button" onClick={copy} whileTap={reduce ? undefined : { scale: 0.94 }} className={cn(action, "ml-auto text-ink-muted hover:text-ink")}>
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.span key={String(copied)} initial={{ opacity: 0, filter: "blur(6px)" }} animate={{ opacity: 1, filter: "blur(0px)" }} exit={{ opacity: 0, filter: "blur(6px)" }} transition={{ duration: 0.18 }} className="inline-flex items-center gap-1.5">
              {copied ? <Check aria-hidden className="size-4 text-volt-ink" strokeWidth={2.5} /> : <Link2 aria-hidden className="size-4" />}
              {copied ? "Link copied" : "Copy link"}
            </motion.span>
          </AnimatePresence>
        </motion.button>
      </div>
    </article>
  );
}

```
