{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"social-posts","type":"registry:component","title":"Social posts","description":"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.","author":"GetUrUI <hello@geturui.dev>","dependencies":["motion","lucide-react"],"registryDependencies":[],"files":[{"path":"registry/geturui/examples/social-posts/text-post.tsx","type":"registry:component","target":"components/examples/social-posts/text-post.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { BadgeCheck, BarChart2, Bookmark, Heart, MessageCircle, MoreHorizontal, Repeat2, type LucideIcon } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface TextPostProps {\n  author?: string;\n  handle?: string;\n  verified?: boolean;\n  /** Relative time label, e.g. \"2h\". */\n  time?: string;\n  /** Paragraphs of the post. */\n  body?: string[];\n  replies?: number;\n  reposts?: number;\n  likes?: number;\n  views?: number;\n  defaultLiked?: boolean;\n  defaultReposted?: boolean;\n  defaultBookmarked?: boolean;\n  onReply?: () => void;\n  onChange?: (state: { liked: boolean; reposted: boolean; bookmarked: boolean }) => void;\n  className?: string;\n}\n\nconst compact = (n: number) => new Intl.NumberFormat(\"en-US\", { notation: \"compact\", maximumFractionDigits: 1 }).format(n);\nconst spring = { type: \"spring\", stiffness: 480, damping: 30 } as const;\nconst BURST = Array.from({ length: 6 }, (_, i) => ({ x: Math.cos((i / 6) * Math.PI * 2) * 16, y: Math.sin((i / 6) * Math.PI * 2) * 16 }));\n\nfunction Rolling({ value, up, reduce }: { value: number; up: boolean; reduce: boolean }) {\n  return (\n    <span className=\"relative inline-flex overflow-hidden tabular-nums\">\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        <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}>\n          {compact(value)}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\nfunction Pop({ on, Icon, fill, reduce }: { on: boolean; Icon: LucideIcon; fill?: boolean; reduce: boolean }) {\n  return (\n    <motion.span initial={reduce || !on ? false : { scale: 0.4 }} animate={{ scale: 1 }} transition={{ type: \"spring\", stiffness: 600, damping: 13 }} className=\"grid\">\n      <Icon aria-hidden className={cn(\"size-[18px]\", on && fill && \"fill-current\")} strokeWidth={on && !fill ? 2.6 : 2} />\n    </motion.span>\n  );\n}\n\n/** Text post with optimistic like, repost and bookmark toggles: icons pop, the like bursts and every count rolls to its new value. */\nexport function TextPost({\n  author = \"Maya Kerrigan\",\n  handle = \"@mayamakes\",\n  verified = true,\n  time = \"2h\",\n  body = [\n    \"Hot take after a year of shipping motion in production: most interfaces don't need more animation, they need shorter animation.\",\n    \"We cut our default duration from 400ms to 180ms and support tickets about the app feeling slow dropped by a third.\",\n  ],\n  replies = 48,\n  reposts = 212,\n  likes = 1840,\n  views = 48200,\n  defaultLiked = false,\n  defaultReposted = false,\n  defaultBookmarked = false,\n  onReply,\n  onChange,\n  className,\n}: TextPostProps) {\n  const reduce = useReducedMotion() ?? false;\n  const [state, setState] = React.useState({ liked: defaultLiked, reposted: defaultReposted, bookmarked: defaultBookmarked });\n  const [burst, setBurst] = React.useState(0);\n  const id = React.useId();\n\n  function flip(key: keyof typeof state) {\n    const next = { ...state, [key]: !state[key] };\n    setState(next);\n    onChange?.(next);\n    if (key === \"liked\" && next.liked && !reduce) setBurst((b) => b + 1);\n  }\n\n  const likeCount = likes + (state.liked ? 1 : 0) - (defaultLiked ? 1 : 0);\n  const repostCount = reposts + (state.reposted ? 1 : 0) - (defaultReposted ? 1 : 0);\n  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)]\";\n\n  return (\n    <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)}>\n      <header className=\"flex items-start gap-3\">\n        <span aria-hidden className=\"grid size-10 shrink-0 place-items-center rounded-full bg-ink font-display text-[13px] font-semibold text-bg\">\n          {author.split(\" \").map((p) => p[0]).join(\"\").slice(0, 2)}\n        </span>\n        <div className=\"min-w-0 flex-1\">\n          <p id={`${id}-a`} className=\"flex items-center gap-1 text-[14px] font-semibold text-ink\">\n            <span className=\"truncate\">{author}</span>\n            {verified && <BadgeCheck aria-label=\"Verified\" className=\"size-4 shrink-0 text-volt-ink\" />}\n          </p>\n          <p className=\"text-[12.5px] text-ink-subtle\">\n            {handle} · {time}\n          </p>\n        </div>\n        <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)]\">\n          <MoreHorizontal aria-hidden className=\"size-4\" />\n        </button>\n      </header>\n\n      <div className=\"mt-3 space-y-2.5 text-[14.5px] leading-[1.55] text-ink\">\n        {body.map((p, i) => (\n          <p key={i}>{p}</p>\n        ))}\n      </div>\n\n      <div className=\"-mx-2 mt-3 flex items-center justify-between\">\n        <button type=\"button\" onClick={onReply} aria-label={`Reply, ${replies} replies`} className={cn(action, \"text-ink-subtle hover:bg-surface-hover hover:text-ink\")}>\n          <MessageCircle aria-hidden className=\"size-[18px]\" />\n          {compact(replies)}\n        </button>\n        <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\")}>\n          <Pop key={`r${state.reposted}`} on={state.reposted} Icon={Repeat2} reduce={reduce} />\n          <Rolling value={repostCount} up={state.reposted} reduce={reduce} />\n        </button>\n        <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\")}>\n          <span className=\"relative grid\">\n            <AnimatePresence>\n              {burst > 0 &&\n                BURST.map((b, i) => (\n                  <motion.span\n                    key={`${burst}-${i}`}\n                    aria-hidden\n                    initial={{ x: 0, y: 0, opacity: 1, scale: 1 }}\n                    animate={{ x: b.x, y: b.y, opacity: 0, scale: 0.3 }}\n                    transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}\n                    className=\"absolute left-1/2 top-1/2 -ml-0.5 -mt-0.5 size-1 rounded-full bg-volt-ink\"\n                  />\n                ))}\n            </AnimatePresence>\n            <Pop key={`l${state.liked}`} on={state.liked} Icon={Heart} fill reduce={reduce} />\n          </span>\n          <Rolling value={likeCount} up={state.liked} reduce={reduce} />\n        </button>\n        <span className=\"inline-flex items-center gap-1.5 px-2 text-[12.5px] text-ink-subtle\">\n          <BarChart2 aria-hidden className=\"size-[18px]\" />\n          <span className=\"sr-only\">Views</span>\n          {compact(views)}\n        </span>\n        <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\")}>\n          <Pop key={`b${state.bookmarked}`} on={state.bookmarked} Icon={Bookmark} fill reduce={reduce} />\n        </button>\n      </div>\n    </article>\n  );\n}\n"},{"path":"registry/geturui/examples/social-posts/image-grid.tsx","type":"registry:component","target":"components/examples/social-posts/image-grid.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { ChevronLeft, ChevronRight, Heart, MessageCircle, X } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type SceneKind = \"dunes\" | \"harbour\" | \"ridge\" | \"market\";\n\nexport interface PostImage {\n  scene: SceneKind;\n  alt: string;\n}\n\nexport interface ImageGridPostProps {\n  author?: string;\n  place?: string;\n  time?: string;\n  caption?: string;\n  images?: PostImage[];\n  likes?: number;\n  comments?: number;\n  onLikeChange?: (liked: boolean) => void;\n  className?: string;\n}\n\nconst DEFAULT_IMAGES: PostImage[] = [\n  { scene: \"dunes\", alt: \"Sand dunes at first light under a pale sun\" },\n  { scene: \"harbour\", alt: \"Fishing boats in a harbour wrapped in fog\" },\n  { scene: \"ridge\", alt: \"Pine ridge in layers of blue haze\" },\n  { scene: \"market\", alt: \"Night market lanterns strung over a street\" },\n];\n\nfunction Scene({ kind }: { kind: SceneKind }) {\n  return (\n    <svg viewBox=\"0 0 160 120\" preserveAspectRatio=\"xMidYMid slice\" className=\"size-full\" aria-hidden>\n      {kind === \"dunes\" && (\n        <>\n          <rect width=\"160\" height=\"120\" fill=\"#f1d9b5\" />\n          <circle cx=\"112\" cy=\"40\" r=\"16\" fill=\"#fff4dd\" />\n          <path d=\"M0 78 Q40 58 84 76 T160 70 V120 H0 Z\" fill=\"#d9a46a\" />\n          <path d=\"M0 96 Q50 80 100 98 T160 92 V120 H0 Z\" fill=\"#b97a45\" />\n        </>\n      )}\n      {kind === \"harbour\" && (\n        <>\n          <rect width=\"160\" height=\"120\" fill=\"#c9d3d8\" />\n          <rect y=\"74\" width=\"160\" height=\"46\" fill=\"#8fa3ad\" />\n          <path d=\"M30 74 L40 52 L40 74 Z M90 74 L102 44 L102 74 Z\" fill=\"#4c5f69\" />\n          <path d=\"M22 74 H54 L50 82 H26 Z M82 74 H118 L113 83 H87 Z\" fill=\"#33434b\" />\n          <rect y=\"60\" width=\"160\" height=\"10\" fill=\"#ffffff\" opacity=\"0.35\" />\n        </>\n      )}\n      {kind === \"ridge\" && (\n        <>\n          <rect width=\"160\" height=\"120\" fill=\"#dfe8ee\" />\n          <path d=\"M0 70 L30 44 L56 62 L88 34 L124 60 L160 40 V120 H0 Z\" fill=\"#9db3c4\" />\n          <path d=\"M0 88 L24 70 L52 84 L80 64 L112 86 L140 70 L160 80 V120 H0 Z\" fill=\"#5d7a8f\" />\n          <path d=\"M0 104 L36 90 L70 104 L110 92 L160 102 V120 H0 Z\" fill=\"#2f4656\" />\n        </>\n      )}\n      {kind === \"market\" && (\n        <>\n          <rect width=\"160\" height=\"120\" fill=\"#1f1d2b\" />\n          <path d=\"M0 26 Q80 50 160 22\" fill=\"none\" stroke=\"#3a3650\" strokeWidth=\"1.5\" />\n          {[16, 40, 64, 88, 112, 136].map((x, i) => (\n            <circle key={x} cx={x} cy={30 + Math.sin(i) * 3 + (i === 2 || i === 3 ? 9 : 4)} r=\"5\" fill={i % 2 ? \"#ffb35c\" : \"#ff7a45\"} />\n          ))}\n          <rect x=\"10\" y=\"78\" width=\"44\" height=\"42\" fill=\"#2c2940\" />\n          <rect x=\"104\" y=\"72\" width=\"48\" height=\"48\" fill=\"#2c2940\" />\n          <rect x=\"16\" y=\"84\" width=\"32\" height=\"8\" fill=\"#ffb35c\" opacity=\"0.6\" />\n        </>\n      )}\n    </svg>\n  );\n}\n\n/** 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. */\nexport function ImageGridPost({\n  author = \"Lena Ortiz\",\n  place = \"Field notes, week 38\",\n  time = \"5h\",\n  caption = \"Four mornings, four places, one camera. The fog in the harbour lifted about ten minutes after this.\",\n  images = DEFAULT_IMAGES,\n  likes = 326,\n  comments = 18,\n  onLikeChange,\n  className,\n}: ImageGridPostProps) {\n  const reduce = useReducedMotion() ?? false;\n  const [view, setView] = React.useState<{ origin: number; index: number } | null>(null);\n  const open = view?.index ?? null;\n  const [liked, setLiked] = React.useState(false);\n  const tiles = React.useRef<(HTMLButtonElement | null)[]>([]);\n  const closeBtn = React.useRef<HTMLButtonElement>(null);\n  const id = React.useId();\n  const shown = images.slice(0, 4);\n\n  const isOpen = view !== null;\n  React.useEffect(() => {\n    if (isOpen) closeBtn.current?.focus();\n  }, [isOpen]);\n\n  function close() {\n    const i = view?.index;\n    setView(null);\n    if (i !== undefined) requestAnimationFrame(() => tiles.current[i]?.focus());\n  }\n\n  const page = (d: number) => setView((v) => (v ? { ...v, index: (v.index + d + shown.length) % shown.length } : v));\n  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)]\";\n  const layout = (i: number) => (reduce ? undefined : `${id}-img-${i}`);\n\n  return (\n    <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)}>\n      <header className=\"flex items-center gap-3\">\n        <span aria-hidden className=\"grid size-10 place-items-center rounded-full bg-ink font-display text-[13px] font-semibold text-bg\">\n          {author.split(\" \").map((p) => p[0]).join(\"\")}\n        </span>\n        <div className=\"min-w-0\">\n          <p className=\"text-[14px] font-semibold text-ink\">{author}</p>\n          <p className=\"text-[12.5px] text-ink-subtle\">\n            {place} · {time}\n          </p>\n        </div>\n      </header>\n      <p className=\"mt-3 text-[14px] leading-[1.5] text-ink\">{caption}</p>\n\n      <div\n        className=\"relative mt-3 aspect-[4/3] overflow-hidden rounded-lg\"\n        onKeyDown={(e) => {\n          if (open === null) return;\n          if (e.key === \"Escape\") close();\n          if (e.key === \"ArrowRight\") page(1);\n          if (e.key === \"ArrowLeft\") page(-1);\n        }}\n      >\n        <div className={cn(\"grid size-full gap-1\", shown.length > 1 && \"grid-cols-2\", shown.length > 2 && \"grid-rows-2\")}>\n          {shown.map((img, i) => (\n            <button\n              key={i}\n              ref={(el) => {\n                tiles.current[i] = el;\n              }}\n              type=\"button\"\n              aria-label={`Open photo ${i + 1} of ${shown.length}: ${img.alt}`}\n              onClick={() => setView({ origin: i, index: i })}\n              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\")}\n            >\n              {view?.origin !== i && (\n                <motion.div layoutId={layout(i)} transition={{ type: \"spring\", stiffness: 300, damping: 32 }} className=\"absolute inset-0 transition-transform duration-500 group-hover:scale-105\">\n                  <Scene kind={img.scene} />\n                </motion.div>\n              )}\n            </button>\n          ))}\n        </div>\n\n        <AnimatePresence>\n          {view !== null && open !== null && (\n            <motion.div key=\"viewer\" role=\"dialog\" aria-label={shown[open].alt} exit={view.origin === open ? undefined : { opacity: 0 }} className=\"absolute inset-0 z-10\">\n              <motion.div layoutId={view.origin === open ? layout(view.origin) : undefined} transition={{ type: \"spring\", stiffness: 300, damping: 32 }} className=\"absolute inset-0\">\n                <AnimatePresence mode=\"popLayout\" initial={false}>\n                  <motion.div key={open} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className=\"absolute inset-0\">\n                    <Scene kind={shown[open].scene} />\n                  </motion.div>\n                </AnimatePresence>\n              </motion.div>\n              <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1, transition: { delay: 0.15 } }} exit={{ opacity: 0, transition: { duration: 0.1 } }} className=\"absolute inset-0\">\n                <button ref={closeBtn} type=\"button\" aria-label=\"Close photo\" onClick={close} className={cn(navBtn, \"absolute right-3 top-3\")}>\n                  <X aria-hidden className=\"size-4\" />\n                </button>\n                <button type=\"button\" aria-label=\"Previous photo\" onClick={() => page(-1)} className={cn(navBtn, \"absolute left-3 top-1/2 -translate-y-1/2\")}>\n                  <ChevronLeft aria-hidden className=\"size-4\" />\n                </button>\n                <button type=\"button\" aria-label=\"Next photo\" onClick={() => page(1)} className={cn(navBtn, \"absolute right-3 top-1/2 -translate-y-1/2\")}>\n                  <ChevronRight aria-hidden className=\"size-4\" />\n                </button>\n                <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\">\n                  {open + 1} of {shown.length} · {shown[open].alt}\n                </p>\n              </motion.div>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n\n      <div className=\"-mx-2 mt-2 flex items-center gap-1\">\n        <motion.button\n          type=\"button\"\n          aria-pressed={liked}\n          aria-label={liked ? \"Unlike\" : \"Like\"}\n          onClick={() => {\n            setLiked(!liked);\n            onLikeChange?.(!liked);\n          }}\n          whileTap={reduce ? undefined : { scale: 0.85 }}\n          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\")}\n        >\n          <Heart aria-hidden className={cn(\"size-[18px]\", liked && \"fill-current\")} />\n          {(likes + (liked ? 1 : 0)).toLocaleString(\"en-US\")}\n        </motion.button>\n        <span className=\"inline-flex h-8 items-center gap-1.5 px-2 text-[12.5px] text-ink-subtle\">\n          <MessageCircle aria-hidden className=\"size-[18px]\" />\n          {comments}\n          <span className=\"sr-only\">comments</span>\n        </span>\n      </div>\n    </article>\n  );\n}\n"},{"path":"registry/geturui/examples/social-posts/poll.tsx","type":"registry:component","target":"components/examples/social-posts/poll.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion, useSpring, useTransform } from \"motion/react\";\nimport { CheckCircle2, Clock } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface PollOption {\n  id: string;\n  label: string;\n  votes: number;\n}\n\nexport interface PollPostProps {\n  author?: string;\n  handle?: string;\n  time?: string;\n  question?: string;\n  options?: PollOption[];\n  /** Label for the time left, e.g. \"2 days left\". */\n  remaining?: string;\n  /** Option id the viewer already voted for. */\n  defaultVote?: string | null;\n  onVote?: (optionId: string | null) => void;\n  className?: string;\n}\n\nconst DEFAULT_OPTIONS: PollOption[] = [\n  { id: \"o1\", label: \"Spring, stiffness 400\", votes: 412 },\n  { id: \"o2\", label: \"Ease out, 200ms\", votes: 268 },\n  { id: \"o3\", label: \"Ease in-out, 300ms\", votes: 157 },\n  { id: \"o4\", label: \"No motion by default\", votes: 89 },\n];\n\nfunction Percent({ value, reduce }: { value: number; reduce: boolean }) {\n  const mv = useSpring(reduce ? value : 0, { stiffness: 120, damping: 22 });\n  const text = useTransform(mv, (v) => `${Math.round(v)}%`);\n  React.useEffect(() => {\n    if (reduce) mv.jump(value);\n    else mv.set(value);\n  }, [mv, value, reduce]);\n  return <motion.span className=\"tabular-nums\">{text}</motion.span>;\n}\n\n/** 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. */\nexport function PollPost({\n  author = \"Jonah Reyes\",\n  handle = \"@jonahbuilds\",\n  time = \"1h\",\n  question = \"Which motion default should ship in our design system v2?\",\n  options = DEFAULT_OPTIONS,\n  remaining = \"2 days left\",\n  defaultVote = null,\n  onVote,\n  className,\n}: PollPostProps) {\n  const reduce = useReducedMotion() ?? false;\n  const [vote, setVote] = React.useState<string | null>(defaultVote);\n  const id = React.useId();\n\n  const counts = options.map((o) => o.votes + (vote === o.id && defaultVote !== o.id ? 1 : 0) - (defaultVote === o.id && vote !== o.id ? 1 : 0));\n  const total = counts.reduce((a, b) => a + b, 0);\n  const pct = counts.map((c) => (total ? (c / total) * 100 : 0));\n  const lead = Math.max(...counts);\n\n  function choose(next: string | null) {\n    setVote(next);\n    onVote?.(next);\n  }\n\n  return (\n    <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)}>\n      <header className=\"flex items-center gap-3\">\n        <span aria-hidden className=\"grid size-10 place-items-center rounded-full bg-ink font-display text-[13px] font-semibold text-bg\">\n          {author.split(\" \").map((p) => p[0]).join(\"\")}\n        </span>\n        <p className=\"min-w-0 text-[14px] font-semibold text-ink\">\n          {author}\n          <span className=\"block text-[12.5px] font-normal text-ink-subtle\">\n            {handle} · {time}\n          </span>\n        </p>\n      </header>\n      <h3 id={`${id}-q`} className=\"mt-3 text-[15px] font-medium leading-[1.45] text-ink\">\n        {question}\n      </h3>\n\n      <AnimatePresence mode=\"wait\" initial={false}>\n        {vote === null ? (\n          <motion.ul key=\"vote\" exit={{ opacity: 0, filter: \"blur(6px)\" }} transition={{ duration: 0.15 }} className=\"mt-3 flex flex-col gap-2\">\n            {options.map((o) => (\n              <li key={o.id}>\n                <motion.button\n                  type=\"button\"\n                  onClick={() => choose(o.id)}\n                  whileTap={reduce ? undefined : { scale: 0.98 }}\n                  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)]\"\n                >\n                  {o.label}\n                </motion.button>\n              </li>\n            ))}\n          </motion.ul>\n        ) : (\n          <motion.ul key=\"results\" aria-label=\"Poll results\" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className=\"mt-3 flex flex-col gap-2\">\n            {options.map((o, i) => {\n              const mine = vote === o.id;\n              const top = counts[i] === lead;\n              return (\n                <li key={o.id} className=\"relative flex h-10 items-center overflow-hidden rounded-sm px-3.5\">\n                  <span aria-hidden className=\"absolute inset-0 bg-surface\" />\n                  <motion.span\n                    aria-hidden\n                    initial={{ scaleX: reduce ? pct[i] / 100 : 0 }}\n                    animate={{ scaleX: pct[i] / 100 }}\n                    transition={{ type: \"spring\", stiffness: 120, damping: 22, delay: reduce ? 0 : i * 0.05 }}\n                    className={cn(\"absolute inset-y-0 left-0 w-full origin-left\", mine ? \"bg-volt/30\" : top ? \"bg-ink/15\" : \"bg-ink/[0.07]\")}\n                  />\n                  <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\")}>\n                    <span className=\"truncate\">{o.label}</span>\n                    {mine && (\n                      <>\n                        <CheckCircle2 aria-hidden className=\"size-4 shrink-0 text-volt-ink\" />\n                        <span className=\"sr-only\">Your vote</span>\n                      </>\n                    )}\n                  </span>\n                  <span className={cn(\"relative text-[13px]\", top ? \"font-semibold text-ink\" : \"text-ink-muted\")}>\n                    <Percent value={pct[i]} reduce={reduce} />\n                  </span>\n                </li>\n              );\n            })}\n          </motion.ul>\n        )}\n      </AnimatePresence>\n\n      <div className=\"mt-3 flex items-center gap-2 text-[12.5px] text-ink-subtle\">\n        <span className=\"tabular-nums\">{total.toLocaleString(\"en-US\")} votes</span>\n        <span aria-hidden>·</span>\n        <span className=\"inline-flex items-center gap-1\">\n          <Clock aria-hidden className=\"size-3.5\" />\n          {remaining}\n        </span>\n        {vote !== null && (\n          <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)]\">\n            Undo vote\n          </button>\n        )}\n      </div>\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {vote ? `Voted for ${options.find((o) => o.id === vote)?.label}. ${options.map((o, i) => `${o.label} ${Math.round(pct[i])}%`).join(\", \")}` : \"\"}\n      </p>\n    </article>\n  );\n}\n"},{"path":"registry/geturui/examples/social-posts/link-preview.tsx","type":"registry:component","target":"components/examples/social-posts/link-preview.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { ArrowUpRight, Check, Globe, Heart, Link2, MessageCircle } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface LinkPreview {\n  url: string;\n  domain: string;\n  title: string;\n  description: string;\n  /** Short label drawn on the preview image. */\n  kicker?: string;\n}\n\nexport interface LinkPreviewPostProps {\n  author?: string;\n  role?: string;\n  time?: string;\n  /** Always-visible opening of the post. */\n  text?: string;\n  /** Revealed by \"Show more\". */\n  more?: string;\n  link?: LinkPreview;\n  likes?: number;\n  comments?: number;\n  onLikeChange?: (liked: boolean) => void;\n  className?: string;\n}\n\nconst DEFAULT_LINK: LinkPreview = {\n  url: \"https://geturui.com/journal/motion-budget\",\n  domain: \"geturui.com\",\n  title: \"A motion budget for product teams\",\n  description: \"How to decide what moves, for how long, and what stays still. With a one-page worksheet.\",\n  kicker: \"Journal\",\n};\n\n/** Post with an expandable body and a rich link preview whose drawn image drifts on hover, plus like and copy-link actions. */\nexport function LinkPreviewPost({\n  author = \"Priya Nair\",\n  role = \"Head of design, Kestrel\",\n  time = \"3h\",\n  text = \"We gave every squad a motion budget this quarter: three signature moments per flow, everything else under 200ms.\",\n  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.\",\n  link = DEFAULT_LINK,\n  likes = 94,\n  comments = 12,\n  onLikeChange,\n  className,\n}: LinkPreviewPostProps) {\n  const reduce = useReducedMotion() ?? false;\n  const [expanded, setExpanded] = React.useState(false);\n  const [liked, setLiked] = React.useState(false);\n  const [copied, setCopied] = React.useState(false);\n  const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n  const moreId = React.useId();\n\n  React.useEffect(() => () => {\n    if (timer.current) clearTimeout(timer.current);\n  }, []);\n\n  async function copy() {\n    try {\n      await navigator.clipboard?.writeText(link.url);\n    } catch {\n      // Clipboard may be blocked; still confirm so the action feels finished.\n    }\n    setCopied(true);\n    if (timer.current) clearTimeout(timer.current);\n    timer.current = setTimeout(() => setCopied(false), 1800);\n  }\n\n  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)]\";\n\n  return (\n    <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)}>\n      <header className=\"flex items-center gap-3\">\n        <span aria-hidden className=\"grid size-10 place-items-center rounded-full bg-ink font-display text-[13px] font-semibold text-bg\">\n          {author.split(\" \").map((p) => p[0]).join(\"\")}\n        </span>\n        <div className=\"min-w-0\">\n          <p className=\"text-[14px] font-semibold text-ink\">{author}</p>\n          <p className=\"truncate text-[12.5px] text-ink-subtle\">\n            {role} · {time}\n          </p>\n        </div>\n      </header>\n\n      <div className=\"mt-3 text-[14px] leading-[1.55] text-ink\">\n        <p>\n          {text}\n          {!expanded && more && (\n            <>\n              {\" \"}\n              <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)]\">\n                Show more\n              </button>\n            </>\n          )}\n        </p>\n        <AnimatePresence initial={false}>\n          {expanded && (\n            <motion.p\n              id={moreId}\n              initial={{ height: 0, opacity: 0, filter: \"blur(4px)\" }}\n              animate={{ height: \"auto\", opacity: 1, filter: \"blur(0px)\" }}\n              transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 260, damping: 30 }}\n              className=\"overflow-hidden\"\n            >\n              <span className=\"block pt-2\">{more}</span>\n            </motion.p>\n          )}\n        </AnimatePresence>\n      </div>\n\n      <a\n        href={link.url}\n        target=\"_blank\"\n        rel=\"noreferrer\"\n        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)]\"\n      >\n        <div aria-hidden className=\"relative h-36 overflow-hidden bg-surface-sunken\">\n          <div className=\"absolute inset-0 transition-transform duration-700 ease-spring-soft group-hover:scale-[1.04]\">\n            <span className=\"absolute -right-6 -top-10 size-40 rounded-full border-[16px] border-ink/[0.07]\" />\n            <span className=\"absolute bottom-5 right-10 h-14 w-24 rounded-md bg-ink/10\" />\n            <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\" />\n          </div>\n          <span className=\"absolute left-4 top-4 font-mono text-[10.5px] uppercase tracking-[0.14em] text-ink-subtle\">{link.kicker}</span>\n          <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>\n        </div>\n        <div className=\"flex items-start gap-3 px-3.5 py-3\">\n          <div className=\"min-w-0 flex-1\">\n            <p className=\"flex items-center gap-1 text-[11.5px] text-ink-subtle\">\n              <Globe aria-hidden className=\"size-3\" />\n              {link.domain}\n            </p>\n            <p className=\"mt-0.5 truncate text-[13.5px] font-semibold text-ink\">{link.title}</p>\n            <p className=\"line-clamp-2 text-[12.5px] leading-[1.45] text-ink-muted\">{link.description}</p>\n          </div>\n          <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\" />\n        </div>\n      </a>\n\n      <div className=\"-mx-2 mt-2 flex items-center gap-1\">\n        <motion.button\n          type=\"button\"\n          aria-pressed={liked}\n          aria-label={liked ? \"Unlike\" : \"Like\"}\n          onClick={() => {\n            setLiked(!liked);\n            onLikeChange?.(!liked);\n          }}\n          whileTap={reduce ? undefined : { scale: 0.85 }}\n          className={cn(action, liked ? \"font-semibold text-volt-ink\" : \"text-ink-subtle\")}\n        >\n          <Heart aria-hidden className={cn(\"size-[18px]\", liked && \"fill-current\")} />\n          {likes + (liked ? 1 : 0)}\n        </motion.button>\n        <span className=\"inline-flex h-8 items-center gap-1.5 px-2 text-[12.5px] text-ink-subtle\">\n          <MessageCircle aria-hidden className=\"size-[18px]\" />\n          {comments}\n          <span className=\"sr-only\">comments</span>\n        </span>\n        <motion.button type=\"button\" onClick={copy} whileTap={reduce ? undefined : { scale: 0.94 }} className={cn(action, \"ml-auto text-ink-muted hover:text-ink\")}>\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            <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\">\n              {copied ? <Check aria-hidden className=\"size-4 text-volt-ink\" strokeWidth={2.5} /> : <Link2 aria-hidden className=\"size-4\" />}\n              {copied ? \"Link copied\" : \"Copy link\"}\n            </motion.span>\n          </AnimatePresence>\n        </motion.button>\n      </div>\n    </article>\n  );\n}\n"},{"path":"LICENSE-geturui.md","type":"registry:file","target":"LICENSE-geturui.md","content":"MIT License\n\nCopyright (c) 2026 geturui.com\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"}],"categories":["examples","social","post","feed","like","repost","poll","image grid","link preview","timeline"],"meta":{"tier":"free","license":"MIT","docs":"https://geturui.com/docs/social-posts"}}