{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "blog-grid",
  "type": "registry:block",
  "title": "Blog grid",
  "description": "A blog index with a featured post, category filter, post cards, pagination and an in-place reader.",
  "categories": [
    "blocks"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/blocks/blog-grid/blog-grid-data.ts",
      "content": "/** Sample posts for the blog grid: the notdocs blog. Covers are one flat inline SVG; authors have no portraits, so the byline shows a plain circle. */\n\nexport type BlogAuthor = { name: string; avatar?: string; role?: string };\n\nexport type BlogPost = {\n  id: string;\n  title: string;\n  excerpt: string;\n  category: string;\n  /** ISO date, used for sorting and the visible date. */\n  date: string;\n  /** Minutes to read. */\n  readTime: number;\n  author: BlogAuthor;\n  image?: { src: string; alt: string };\n  /** Paragraphs for the in-place reader. Leave out when every post links out through getHref. */\n  body?: string[];\n  featured?: boolean;\n};\n\n/** One flat cover in muted blue-grey shapes, shared by every sample post. */\nconst cover = {\n  src: \"data:image/svg+xml,%3Csvg class='iris-placeholder' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1600 1000'%3E%3Crect width='1600' height='1000' fill='%23e3e7ee'/%3E%3Crect x='0' y='640' width='1600' height='360' fill='%23d3d9e3'/%3E%3Ccircle cx='1180' cy='330' r='150' fill='%23c4ccd9'/%3E%3Crect x='220' y='420' width='420' height='320' rx='14' fill='%23cbd2de'/%3E%3C/svg%3E\",\n  alt: \"Flat cover with a circle and a panel on a grey ground\",\n};\n\nconst katerina: BlogAuthor = { name: \"Katerina Vlachou\", role: \"Product lead\", avatar: \"\" };\nconst stelios: BlogAuthor = { name: \"Stelios Antoniou\", role: \"Backend engineer\", avatar: \"\" };\nconst nefeli: BlogAuthor = { name: \"Nefeli Kontou\", role: \"Product designer\", avatar: \"\" };\nconst tomasz: BlogAuthor = { name: \"Tomasz Wrona\", role: \"Frontend engineer\", avatar: \"\" };\n\nexport const blogCategories = [\"Product\", \"Design\", \"Engineering\", \"Company\"];\n\nexport const blogPosts: BlogPost[] = [\n  {\n    id: \"files-in-your-repo\",\n    featured: true,\n    category: \"Product\",\n    date: \"2026-10-06\",\n    readTime: 7,\n    author: katerina,\n    image: cover,\n    title: \"Your workspace is a folder in your repo\",\n    excerpt:\n      \"Pages, tasks and decisions are markdown files. People edit them in the browser, agents edit them over MCP, and nothing syncs.\",\n    body: [\n      \"notdocs keeps every document as a markdown file in the project's git repository. The web editor, the CLI and the MCP server all write to the same files.\",\n      \"There is no second copy to keep in step, so there is one history: the commits. A change made by a person and a change made by an agent sit side by side in it.\",\n      \"Agents connect with a project token and appear as name@agent, so you can always see who wrote what.\",\n    ],\n  },\n  {\n    id: \"sign-in-on-notdocs-dev\",\n    category: \"Product\",\n    date: \"2026-10-01\",\n    readTime: 2,\n    author: tomasz,\n    image: cover,\n    title: \"Sign in straight from notdocs.dev\",\n    excerpt:\n      \"In 1.6.0, /sign-in and /sign-up on notdocs.dev open the workspace. Before, they went to a separate page first.\",\n    body: [\"One less step between the site and your documents.\"],\n  },\n  {\n    id: \"print-preview\",\n    category: \"Product\",\n    date: \"2026-09-30\",\n    readTime: 3,\n    author: nefeli,\n    image: cover,\n    title: \"Print or save any document as PDF\",\n    excerpt: \"1.5.0 adds a print preview, so a spec, a proposal or an invoice leaves the workspace as a clean page.\",\n    body: [\"Open the print preview from any document. Print it, or save it as PDF from the same dialog.\"],\n  },\n  {\n    id: \"fits-a-phone\",\n    category: \"Design\",\n    date: \"2026-09-26\",\n    readTime: 5,\n    author: nefeli,\n    image: cover,\n    title: \"The workspace fits a phone\",\n    excerpt:\n      \"Reading a decision on the train should not take pinching and sideways scrolling. Since 1.5.0 it does not.\",\n    body: [\n      \"Before 1.5.0 the workspace kept its desktop layout on small screens. Now documents use the full width of a phone.\",\n    ],\n  },\n  {\n    id: \"typing-lost\",\n    category: \"Engineering\",\n    date: \"2026-09-24\",\n    readTime: 9,\n    author: stelios,\n    image: cover,\n    title: \"At most 1.5 s of typing lost on a crash\",\n    excerpt: \"How the editor writes back to the file while you type, and what a server crash can still cost you.\",\n    body: [\n      \"The editor writes your changes back to the markdown file as you type. If the server stops mid sentence, you lose at most the last 1.5 s.\",\n      \"This post walks through where that number comes from.\",\n    ],\n  },\n  {\n    id: \"seat-based-pricing\",\n    category: \"Company\",\n    date: \"2026-09-15\",\n    readTime: 4,\n    author: katerina,\n    image: cover,\n    title: \"Why we moved to seat-based pricing\",\n    excerpt:\n      \"The decision document, as accepted: Free at €0, Solo at €12/month for 3 seats, Team at €39/month for 10 seats.\",\n    body: [\n      \"We wrote the pricing change as a decision in our own workspace. It moved from proposed to accepted on 15 Sep 2026, and this post is that document.\",\n    ],\n  },\n  {\n    id: \"query-blocks\",\n    category: \"Product\",\n    date: \"2026-09-10\",\n    readTime: 5,\n    author: tomasz,\n    image: cover,\n    title: \"One query block, five views\",\n    excerpt: \"Show the same tasks as a board, a table, a list, a calendar or a gallery, read straight from the files.\",\n    body: [\n      \"A query block reads documents by type and status and lays them out. Switch the view and the files stay as they are.\",\n    ],\n  },\n  {\n    id: \"decision-documents\",\n    category: \"Design\",\n    date: \"2026-09-03\",\n    readTime: 6,\n    author: nefeli,\n    image: cover,\n    title: \"Designing the decision document\",\n    excerpt: \"Proposed, accepted, rejected, superseded. Four states and a date turned out to be enough.\",\n    body: [\n      \"A decision is a markdown file with a status and a date in its frontmatter. The page shows the status first and the reasoning below it.\",\n    ],\n  },\n  {\n    id: \"agents-as-authors\",\n    category: \"Engineering\",\n    date: \"2026-08-27\",\n    readTime: 8,\n    author: stelios,\n    image: cover,\n    title: \"Agents show up as name@agent\",\n    excerpt:\n      \"claude-code@agent and docs-bot@agent edit the same documents as you, and their changes land in the same history.\",\n    body: [\n      \"Every agent gets its own name, so a comment or a commit from an agent is never mistaken for one from a person.\",\n    ],\n  },\n  {\n    id: \"capydb-index\",\n    category: \"Engineering\",\n    date: \"2026-08-20\",\n    readTime: 6,\n    author: stelios,\n    image: cover,\n    title: \"Why the hosted index runs on CapyDB\",\n    excerpt: \"The decision record behind our search index, from proposed to accepted.\",\n    body: [\"The hosted index needed its own Postgres. We wrote the options down as a decision and accepted CapyDB.\"],\n  },\n  {\n    id: \"custom-domains\",\n    category: \"Product\",\n    date: \"2026-08-13\",\n    readTime: 3,\n    author: tomasz,\n    image: cover,\n    title: \"Custom domains show a change at once\",\n    excerpt: \"1.4.0: point a public site at your own domain and the change shows right away.\",\n    body: [\"Custom domains come with the Team plan. Change one in settings and the public site answers on it at once.\"],\n  },\n  {\n    id: \"status-colours\",\n    category: \"Design\",\n    date: \"2026-08-06\",\n    readTime: 4,\n    author: nefeli,\n    image: cover,\n    title: \"Status colours that read at a glance\",\n    excerpt: \"Todo grey, doing wine, blocked rust, review ochre, done green, and why each one earned its place.\",\n    body: [\n      \"Five task states, five colours. Everything else in the workspace stays in warm greys so the states stand out.\",\n    ],\n  },\n  {\n    id: \"who-it-is-for\",\n    category: \"Company\",\n    date: \"2026-07-30\",\n    readTime: 3,\n    author: katerina,\n    image: cover,\n    title: \"Who notdocs is for\",\n    excerpt: \"Indie hackers and small product teams who already ship with coding agents and keep their work in git.\",\n    body: [\"If your agents already edit your code, they can edit your specs and tasks too. That is the whole idea.\"],\n  },\n];\n",
      "type": "registry:component",
      "target": "@components/iris/blocks/blog-grid/blog-grid-data.ts"
    },
    {
      "path": "src/registry/blocks/blog-grid/blog-grid.tsx",
      "content": "\"use client\";\n/* eslint-disable @next/next/no-img-element -- plain images keep the block portable outside Next.js. */\n\nimport { useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport type { MouseEvent, ReactNode } from \"react\";\nimport { AnimatePresence, LayoutGroup, animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport { ArrowLeft, ChevronLeft, ChevronRight } from \"lucide-react\";\nimport { motionTokens } from \"../../lib/motion-tokens\";\nimport { cn } from \"../../lib/utils\";\nimport { blogCategories, blogPosts } from \"./blog-grid-data\";\nimport type { BlogPost } from \"./blog-grid-data\";\n\nexport type { BlogAuthor, BlogPost } from \"./blog-grid-data\";\n\nexport interface BlogGridProps {\n  title?: string;\n  description?: string;\n  posts?: BlogPost[];\n  /** Category filter labels, in order. \"All\" is added in front. */\n  categories?: string[];\n  /** Active category, or \"All\" (controlled). */\n  category?: string;\n  defaultCategory?: string;\n  onCategoryChange?: (category: string) => void;\n  /** One based page (controlled). */\n  page?: number;\n  defaultPage?: number;\n  onPageChange?: (page: number) => void;\n  /** Cards per page below the featured post. Defaults to 6. */\n  pageSize?: number;\n  /** Shows the newest post of the current filter as a large card on page one. Defaults to true. */\n  showFeatured?: boolean;\n  /** Link for each post. When set, cards render as links and the in-place reader is off. */\n  getHref?: (post: BlogPost) => string;\n  /** Called when a post opens, from a link or the in-place reader. */\n  onPostOpen?: (post: BlogPost) => void;\n  className?: string;\n}\n\nconst smooth = motionTokens.spring.smooth;\nconst morph = motionTokens.spring.morph;\nconst standard = [...motionTokens.ease.standard] as [number, number, number, number];\nconst dateFormat = new Intl.DateTimeFormat(\"en-US\", {\n  month: \"short\",\n  day: \"numeric\",\n  year: \"numeric\",\n  timeZone: \"UTC\",\n});\n/** Dates read day first with a three-letter month, such as \"6 Oct 2026\". */\nconst formatDate = (iso: string) => {\n  const parts = dateFormat.formatToParts(new Date(`${iso}T00:00:00Z`));\n  const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((item) => item.type === type)?.value ?? \"\";\n  return `${part(\"day\")} ${part(\"month\")} ${part(\"year\")}`;\n};\n\n/* Container steps of the `blog` container: 900 px (two columns), 680 px (tighter gutters, stacked featured post), 540 px (one column). */\nconst displayTitle = \"m-0 font-display font-medium leading-display tracking-display text-balance\";\nconst pill =\n  \"relative isolate cursor-pointer rounded-button text-sm/[inherit] font-medium text-ink-2 [-webkit-tap-highlight-color:transparent]\";\nconst highlight = \"absolute inset-0 -z-1 rounded-[inherit]\";\n/** The cover zooms a little while its card is hovered, and the frame dips while it is pressed. */\nconst image =\n  \"block size-full object-cover transition-transform duration-considered ease-standard motion-safe:group-hover/card:[transform:scale(1.03)] motion-reduce:transition-none\";\nconst avatar = \"size-6 flex-none rounded-full bg-hover object-cover\";\nconst meta =\n  \"m-0 flex flex-wrap gap-1.5 text-xs/[inherit] text-ink-3 [&>span:first-child]:font-medium [&>span:first-child]:text-ink\";\nconst pageButton = cn(\n  pill,\n  \"inline-flex h-9 min-w-9 items-center justify-center gap-1.5 tabular-nums [transition:color_var(--duration-fast)_var(--ease-standard),transform_var(--duration-instant)_var(--ease-standard)]\",\n);\n/** Previous and next drop their words below 540 px and keep the chevrons. */\nconst pageStep = cn(\n  pageButton,\n  \"px-3 enabled:hover:text-ink enabled:active:[transform:scale(.97)] disabled:cursor-default disabled:text-ink-3 disabled:opacity-50 @max-[540px]/blog:p-0 @max-[540px]/blog:[&_span]:hidden\",\n);\nconst paragraph = \"m-0 text-lg/[1.65] text-ink-2 text-pretty @max-[680px]/blog:text-base/[1.65]\";\n\nfunction useControllable<T>(value: T | undefined, initial: T, onChange?: (next: T) => void) {\n  const [inner, setInner] = useState(initial);\n  const current = value !== undefined ? value : inner;\n  const set = (next: T) => {\n    if (value === undefined) setInner(next);\n    onChange?.(next);\n  };\n  return [current, set] as const;\n}\n\n/** Springs its height when the page or filter changes, so the pagination below glides instead of jumping. Other resizes (a late font, a narrower window) apply at once. */\nfunction AutoHeight({\n  changeKey,\n  reduced,\n  className,\n  children,\n}: {\n  changeKey: string;\n  reduced: boolean;\n  className?: string;\n  children: ReactNode;\n}) {\n  const clipRef = useRef<HTMLDivElement>(null);\n  const contentRef = useRef<HTMLDivElement>(null);\n  const height = useMotionValue<number | \"auto\">(\"auto\");\n  const lastKey = useRef(changeKey),\n    armedUntil = useRef(0);\n  useLayoutEffect(() => {\n    if (lastKey.current === changeKey) return;\n    lastKey.current = changeKey;\n    armedUntil.current = performance.now() + 900;\n  }, [changeKey]);\n  useEffect(() => {\n    const clip = clipRef.current,\n      content = contentRef.current;\n    if (!clip || !content || typeof ResizeObserver === \"undefined\") return;\n    let controls: ReturnType<typeof animate> | undefined;\n    const observer = new ResizeObserver(() => {\n      const next = content.offsetHeight;\n      controls?.stop();\n      if (!next || reduced || height.get() === \"auto\" || performance.now() > armedUntil.current) {\n        height.jump(next || \"auto\");\n        delete clip.dataset.clip;\n        return;\n      }\n      clip.dataset.clip = \"\";\n      controls = animate(height, next, {\n        ...smooth,\n        onComplete: () => {\n          delete clip.dataset.clip;\n        },\n      });\n    });\n    observer.observe(content);\n    return () => {\n      observer.disconnect();\n      controls?.stop();\n    };\n  }, [height, reduced]);\n  return (\n    <motion.div ref={clipRef} className=\"min-w-0 data-clip:overflow-hidden\" style={{ height }}>\n      <div ref={contentRef} className={className}>\n        {children}\n      </div>\n    </motion.div>\n  );\n}\n\nfunction Meta({ post }: { post: BlogPost }) {\n  return (\n    <p className={meta}>\n      <span>{post.category}</span>\n      <span aria-hidden=\"true\">·</span>\n      <time dateTime={post.date}>{formatDate(post.date)}</time>\n    </p>\n  );\n}\n\nfunction Byline({ post }: { post: BlogPost }) {\n  return (\n    <div className=\"mt-2 flex min-w-0 items-center gap-2 text-sm/[inherit]\">\n      {post.author.avatar ? (\n        <img className={avatar} src={post.author.avatar} alt=\"\" width={24} height={24} loading=\"lazy\" />\n      ) : (\n        <span className={avatar} aria-hidden=\"true\" />\n      )}\n      <span className=\"min-w-0 truncate\">{post.author.name}</span>\n      <span className=\"flex-none text-ink-3 before:mr-2 before:content-['·']\">{post.readTime} min read</span>\n    </div>\n  );\n}\n\nexport function BlogGrid({\n  title = \"Blog\",\n  description = \"Release notes, design decisions and engineering write-ups from the notdocs team.\",\n  posts = blogPosts,\n  categories = blogCategories,\n  category: categoryProp,\n  defaultCategory = \"All\",\n  onCategoryChange,\n  page: pageProp,\n  defaultPage = 1,\n  onPageChange,\n  pageSize = 6,\n  showFeatured = true,\n  getHref,\n  onPostOpen,\n  className,\n}: BlogGridProps) {\n  const reduced = useReducedMotion();\n  const uid = useId();\n  const rootRef = useRef<HTMLElement>(null);\n  const [category, setCategoryState] = useControllable(categoryProp, defaultCategory, onCategoryChange);\n  const [page, setPageState] = useControllable(pageProp, defaultPage, onPageChange);\n  const [direction, setDirection] = useState(0);\n  const [reading, setReading] = useState<BlogPost | null>(null);\n  /** The post the reader just closed. Its image morphs back into its card while the rest of the index fades in around it. */\n  const [returning, setReturning] = useState<string | null>(null);\n  const lastOpened = useRef<string | null>(null);\n  const backRef = useRef<HTMLButtonElement>(null);\n  /* The reader moves focus to its back button as it opens. */\n  useEffect(() => {\n    if (reading) backRef.current?.focus();\n  }, [reading]);\n\n  const sorted = posts.toSorted((a, b) => b.date.localeCompare(a.date));\n  const filtered = category === \"All\" ? sorted : sorted.filter((post) => post.category === category);\n  const featured = showFeatured && filtered.length > 1 ? (filtered.find((post) => post.featured) ?? filtered[0]) : null;\n  const rest = featured ? filtered.filter((post) => post !== featured) : filtered;\n  const pageCount = Math.max(1, Math.ceil(rest.length / pageSize));\n  const safePage = Math.min(Math.max(1, page), pageCount);\n  const pagePosts = rest.slice((safePage - 1) * pageSize, safePage * pageSize);\n\n  function scrollToTop() {\n    const node = rootRef.current;\n    if (node && node.getBoundingClientRect().top < 0)\n      node.scrollIntoView({ behavior: reduced ? \"auto\" : \"smooth\", block: \"start\" });\n  }\n  function setCategory(next: string) {\n    if (next === category) return;\n    setDirection(0);\n    setReturning(null);\n    setCategoryState(next);\n    setPageState(1);\n  }\n  function setPage(next: number) {\n    if (next === safePage || next < 1 || next > pageCount) return;\n    setDirection(next > safePage ? 1 : -1);\n    setReturning(null);\n    setPageState(next);\n    scrollToTop();\n  }\n  function open(post: BlogPost, event: MouseEvent) {\n    onPostOpen?.(post);\n    if (getHref) return;\n    event.preventDefault();\n    lastOpened.current = post.id;\n    setReading(post);\n    scrollToTop();\n  }\n  function close() {\n    setReturning(lastOpened.current);\n    setReading(null);\n    requestAnimationFrame(() => {\n      const card = rootRef.current?.querySelector<HTMLElement>(`[data-post=\"${lastOpened.current}\"]`);\n      card?.focus({ preventScroll: true });\n      card?.scrollIntoView({ block: \"nearest\", behavior: reduced ? \"auto\" : \"smooth\" });\n    });\n  }\n\n  const renderCard = (post: BlogPost, isFeatured = false) => {\n    const href = getHref?.(post);\n    const inner = (\n      <>\n        {post.image && (\n          <motion.div\n            layoutId={reduced ? undefined : `${uid}-image-${post.id}`}\n            transition={morph}\n            className={cn(\n              \"relative aspect-[3/2] overflow-hidden rounded-[20px] bg-hover transition-transform duration-fast ease-standard group-active/card:[transform:scale(.99)] motion-reduce:transition-none\",\n              isFeatured &&\n                \"aspect-[16/10] rounded-[26px] @max-[680px]/blog:aspect-[3/2] @max-[680px]/blog:rounded-[20px]\",\n            )}\n          >\n            <img className={image} src={post.image.src} alt={post.image.alt} loading={isFeatured ? \"eager\" : \"lazy\"} />\n          </motion.div>\n        )}\n        <div className={cn(\"grid content-start gap-2\", isFeatured && \"gap-3\")}>\n          <Meta post={post} />\n          <h3\n            className={\n              isFeatured\n                ? cn(\"text-[length:clamp(var(--text-2xl),1rem_+_2.2cqi,var(--text-3xl))]\", displayTitle)\n                : \"m-0 text-lg/[1.3] font-medium text-balance transition-[color] duration-fast ease-standard group-hover/card:text-ink-2\"\n            }\n          >\n            {post.title}\n          </h3>\n          <p\n            className={cn(\n              \"m-0 line-clamp-2 text-sm/[1.5] text-ink-2 text-pretty\",\n              isFeatured && \"line-clamp-3 text-base/[1.5]\",\n            )}\n          >\n            {post.excerpt}\n          </p>\n          <Byline post={post} />\n        </div>\n      </>\n    );\n    const cardClass = cn(\n      \"group/card grid content-start gap-4 rounded-[22px] text-inherit no-underline [-webkit-tap-highlight-color:transparent]\",\n      isFeatured &&\n        \"grid-cols-[minmax(0,1.35fr)_minmax(0,1fr)] items-center gap-10 @max-[900px]/blog:gap-6 @max-[680px]/blog:grid-cols-1 @max-[680px]/blog:gap-4\",\n    );\n    const fades = returning !== null && (reduced || post.id !== returning);\n    return (\n      <motion.a\n        key={post.id}\n        className={cardClass}\n        href={href ?? `#${post.id}`}\n        data-post={post.id}\n        onClick={(event) => open(post, event)}\n        {...enterFade(fades)}\n      >\n        {inner}\n      </motion.a>\n    );\n  };\n\n  /** Fades a piece of the index or reader in on its own, so the shared image is never dimmed by a fading parent mid morph. */\n  const enterFade = (on: boolean) => ({\n    initial: on ? { opacity: 0 } : (false as const),\n    animate: { opacity: 1 },\n    transition: { duration: motionTokens.duration.standard, ease: standard },\n  });\n  const back = returning !== null;\n  const tabs = [\"All\", ...categories];\n  const pageKey = `${category}-${safePage}`;\n\n  return (\n    <section\n      ref={rootRef}\n      className={cn(\"@container/blog w-full min-w-0 bg-page font-body tracking-body text-ink\", className)}\n      aria-label={title}\n    >\n      <LayoutGroup id={uid}>\n        <div className=\"relative mx-auto max-w-[1180px] px-6 py-16 @max-[680px]/blog:px-4 @max-[680px]/blog:py-12\">\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            {reading ? (\n              <motion.article\n                key=\"reader\"\n                className=\"mx-auto grid max-w-[760px] gap-8\"\n                exit={{ opacity: 0, transition: { duration: motionTokens.duration.exit, ease: standard } }}\n                aria-labelledby={`${uid}-reader-title`}\n              >\n                <motion.button\n                  type=\"button\"\n                  className=\"-ml-2 inline-flex h-9 w-fit cursor-pointer items-center gap-1.5 rounded-button pr-3 pl-2 text-sm/[inherit] font-medium text-ink-2 [transition:color_var(--duration-fast)_var(--ease-standard),background-color_var(--duration-fast)_var(--ease-standard)] hover:bg-hover hover:text-ink\"\n                  onClick={close}\n                  ref={backRef}\n                  {...enterFade(true)}\n                >\n                  <ArrowLeft size={16} aria-hidden=\"true\" />\n                  All posts\n                </motion.button>\n                <motion.header className=\"grid gap-3\" {...enterFade(true)}>\n                  <Meta post={reading} />\n                  <h2\n                    id={`${uid}-reader-title`}\n                    className={cn(\"text-[length:clamp(var(--text-2xl),1rem_+_3.4cqi,var(--text-4xl))]\", displayTitle)}\n                  >\n                    {reading.title}\n                  </h2>\n                  <Byline post={reading} />\n                </motion.header>\n                {reading.image && (\n                  <motion.div\n                    layoutId={reduced ? undefined : `${uid}-image-${reading.id}`}\n                    transition={morph}\n                    className=\"aspect-video overflow-hidden rounded-[26px] bg-hover\"\n                    {...(reduced ? enterFade(true) : {})}\n                  >\n                    <img className={image} src={reading.image.src} alt={reading.image.alt} />\n                  </motion.div>\n                )}\n                <motion.div\n                  className=\"grid gap-5\"\n                  initial={{ opacity: 0, y: reduced ? 0 : 12 }}\n                  animate={{ opacity: 1, y: 0 }}\n                  transition={{ ...smooth, delay: reduced ? 0 : 0.12 }}\n                >\n                  <p className={cn(paragraph, \"text-xl/[1.5] text-ink @max-[680px]/blog:text-lg/[1.5]\")}>\n                    {reading.excerpt}\n                  </p>\n                  {(reading.body ?? []).map((text) => (\n                    <p key={text} className={paragraph}>\n                      {text}\n                    </p>\n                  ))}\n                </motion.div>\n              </motion.article>\n            ) : (\n              <motion.div\n                key=\"index\"\n                className=\"grid gap-8\"\n                exit={{ opacity: 0, transition: { duration: motionTokens.duration.exit, ease: standard } }}\n              >\n                <motion.header className=\"grid max-w-[600px] gap-3\" {...enterFade(back)}>\n                  <h2 className={cn(\"text-[length:clamp(1.75rem,1rem_+_3cqi,var(--text-4xl))]\", displayTitle)}>\n                    {title}\n                  </h2>\n                  {description && <p className=\"m-0 text-base leading-body text-ink-2 text-pretty\">{description}</p>}\n                </motion.header>\n\n                <motion.fieldset\n                  className=\"-mx-6 flex min-w-0 gap-0.5 overflow-x-auto px-6 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden @max-[680px]/blog:-mx-4 @max-[680px]/blog:px-4\"\n                  aria-label=\"Filter by category\"\n                  {...enterFade(back)}\n                >\n                  {tabs.map((tab) => (\n                    <button\n                      key={tab}\n                      type=\"button\"\n                      className={cn(\n                        pill,\n                        \"h-9 flex-none whitespace-nowrap px-4 transition-[color] duration-fast ease-standard aria-pressed:text-page hover:aria-[pressed=false]:text-ink\",\n                      )}\n                      aria-pressed={tab === category}\n                      onClick={() => setCategory(tab)}\n                    >\n                      {tab === category && (\n                        <motion.span\n                          layoutId={`${uid}-filter`}\n                          className={cn(highlight, \"bg-ink\")}\n                          transition={reduced ? { duration: 0 } : morph}\n                        />\n                      )}\n                      <span>{tab}</span>\n                    </button>\n                  ))}\n                </motion.fieldset>\n\n                <AutoHeight changeKey={`${pageKey}-${pageCount}`} reduced={Boolean(reduced)} className=\"grid gap-8\">\n                  <AnimatePresence mode=\"wait\" initial={false} custom={direction}>\n                    <motion.div\n                      key={pageKey}\n                      className=\"grid gap-12 @max-[680px]/blog:gap-10\"\n                      initial={reduced ? { opacity: 0 } : { opacity: 0, x: direction * 24, y: direction ? 0 : 10 }}\n                      animate={{ opacity: 1, x: 0, y: 0 }}\n                      exit={\n                        reduced\n                          ? { opacity: 0 }\n                          : {\n                              opacity: 0,\n                              x: direction * -24,\n                              transition: { duration: motionTokens.duration.exit, ease: standard },\n                            }\n                      }\n                      transition={{ ...smooth, opacity: { duration: motionTokens.duration.standard } }}\n                    >\n                      {featured && safePage === 1 && renderCard(featured, true)}\n                      {pagePosts.length > 0 && (\n                        <div className=\"grid grid-cols-3 gap-x-6 gap-y-12 @max-[900px]/blog:grid-cols-2 @max-[680px]/blog:gap-x-4 @max-[680px]/blog:gap-y-10 @max-[540px]/blog:grid-cols-1\">\n                          {pagePosts.map((post) => renderCard(post))}\n                        </div>\n                      )}\n                      {filtered.length === 0 && (\n                        <p className=\"m-0 py-16 text-center text-ink-2\">No posts in {category} yet.</p>\n                      )}\n                    </motion.div>\n                  </AnimatePresence>\n\n                  <AnimatePresence initial={back}>\n                    {pageCount > 1 && (\n                      <motion.nav\n                        key=\"pagination\"\n                        className=\"relative flex items-center justify-between gap-3 pt-6 before:absolute before:inset-x-0 before:top-0 before:h-px before:bg-line before:content-['']\"\n                        aria-label=\"Pagination\"\n                        initial={{ opacity: 0 }}\n                        animate={{ opacity: 1 }}\n                        exit={{ opacity: 0, transition: { duration: motionTokens.duration.exit, ease: standard } }}\n                        transition={{ duration: motionTokens.duration.standard, ease: standard }}\n                      >\n                        <button\n                          type=\"button\"\n                          className={pageStep}\n                          onClick={() => setPage(safePage - 1)}\n                          disabled={safePage === 1}\n                          aria-label=\"Previous page\"\n                        >\n                          <ChevronLeft size={16} aria-hidden=\"true\" />\n                          <span>Previous</span>\n                        </button>\n                        <div className=\"flex gap-0.5\">\n                          {Array.from({ length: pageCount }, (_, index) => index + 1).map((number) => (\n                            <button\n                              key={number}\n                              type=\"button\"\n                              className={cn(pageButton, \"p-0 hover:text-ink aria-[current=page]:text-ink\")}\n                              aria-current={number === safePage ? \"page\" : undefined}\n                              aria-label={`Page ${number}`}\n                              onClick={() => setPage(number)}\n                            >\n                              {number === safePage && (\n                                <motion.span\n                                  layoutId={`${uid}-page`}\n                                  className={cn(highlight, \"bg-hover\")}\n                                  transition={reduced ? { duration: 0 } : morph}\n                                />\n                              )}\n                              <span>{number}</span>\n                            </button>\n                          ))}\n                        </div>\n                        <button\n                          type=\"button\"\n                          className={pageStep}\n                          onClick={() => setPage(safePage + 1)}\n                          disabled={safePage === pageCount}\n                          aria-label=\"Next page\"\n                        >\n                          <span>Next</span>\n                          <ChevronRight size={16} aria-hidden=\"true\" />\n                        </button>\n                      </motion.nav>\n                    )}\n                  </AnimatePresence>\n                </AutoHeight>\n              </motion.div>\n            )}\n          </AnimatePresence>\n        </div>\n      </LayoutGroup>\n    </section>\n  );\n}\n\n/** Preview: the blog index with the in-place reader. */\nexport function BlogGridBlock() {\n  return <BlogGrid />;\n}\n\nexport default BlogGridBlock;\n",
      "type": "registry:component",
      "target": "@components/iris/blocks/blog-grid/blog-grid.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/blog-grid",
  "meta": {
    "kind": "block",
    "group": "Blocks",
    "export": "BlogGrid",
    "docs": "https://irisui.dev/components/blog-grid"
  }
}
