# 블로그 그리드

카테고리 필터와 페이지 나누기가 있는 글 카드 목록이에요.

- Type: block
- Theme: base
- Tags: arc, block, blog, grid, cards, filter, pagination
- English title: Blog grid
- URL: https://dsui.vercel.app/items/blog-grid

## Controls

| Key | Type | Label | Default |
| --- | --- | --- | --- |
| title | text | 제목 | 블로그 |
| description | text | 설명 | 팀이 배운 것을 나눠요. |
| pageSize | slider | 페이지당 글 | 6 |
| showFeatured | boolean | 대표 글 | true |

## Source

### blog-grid-data.ts

```ts
/** Sample posts for the blog grid. Photos and portraits live in public/media (credits in public/media/CREDITS.md). */

export type BlogAuthor = { name: string; avatar?: string; role?: string };

export type BlogPost = {
  id: string;
  title: string;
  excerpt: string;
  category: string;
  /** ISO date, used for sorting and the visible date. */
  date: string;
  /** Minutes to read. */
  readTime: number;
  author: BlogAuthor;
  image?: { src: string; alt: string };
  /** Paragraphs for the in-place reader. Leave out when every post links out through getHref. */
  body?: string[];
  featured?: boolean;
};

const emma: BlogAuthor = { name: "이하은", role: "프로덕트 디자이너", avatar: "/media/people/emma-collins.jpg" };
const marcus: BlogAuthor = { name: "박도현", role: "프런트엔드 개발자", avatar: "/media/people/marcus-johnson.jpg" };
const jasmine: BlogAuthor = { name: "김서연", role: "디자인 리드", avatar: "/media/people/jasmine-brooks.jpg" };
const daniel: BlogAuthor = { name: "김민준", role: "백엔드 개발자", avatar: "/media/people/daniel-kim.jpg" };
const sofia: BlogAuthor = { name: "정수아", role: "운영 리드", avatar: "/media/people/sofia-ramirez.jpg" };
const chloe: BlogAuthor = { name: "최지우", role: "데이터 분석가", avatar: "/media/people/chloe-nguyen.jpg" };
const nathan: BlogAuthor = { name: "한지훈", role: "개발 매니저", avatar: "/media/people/nathan-cole.jpg" };

export const blogCategories = ["제품", "디자인", "개발", "회사"];

export const blogPosts: BlogPost[] = [
  {
    id: "quiet-software", featured: true, category: "디자인", date: "2026-09-18", readTime: 7, author: jasmine,
    title: "조용한 소프트웨어를 만들어야 하는 이유",
    excerpt: "화면에서 색을 절반으로 줄였더니, 사람들이 일을 더 빨리 끝내기 시작했어요.",
    image: { src: "/media/photos/living-room.jpg", alt: "나무 들보와 아치형 창, 크림색 소파가 있는 밝은 거실" },
    body: [
      "지난봄에 실험을 하나 했어요. 제품에서 가장 복잡한 화면을 골라, 의미를 담지 않은 색을 모두 걷어 냈어요. 상태 색은 남기고, 선택 색도 남기고, 나머지는 모두 무채색으로 바꿨어요.",
      "요청한 사람은 없었고, 바뀐 걸 바로 알아챈 사람도 거의 없었어요. 다만 화면이 한결 편해졌다고 느꼈어요. 첫 달에 작업 완료율이 11% 올랐어요.",
      "조용하다는 건 비어 있다는 뜻이 아니에요. 모든 요소에 맡은 일이 있고, 눈에 띄는 순간은 주목이 필요한 곳에만 아껴 둔다는 뜻이에요.",
    ],
  },
  {
    id: "offline-sync", category: "개발", date: "2026-09-12", readTime: 9, author: daniel,
    title: "오프라인 동기화는 실제로 어떻게 동작할까",
    excerpt: "충돌 없는 복제 데이터를, 출시까지 마주친 버그와 함께 풀어 봤어요.",
    image: { src: "/media/photos/curved-facade.jpg", alt: "곡선형 발코니가 있는 흰 타일 건물 외벽" },
    body: ["모든 편집은 먼저 기기에 저장되고, 연결이 되면 서버로 보내져요. 어려운 부분은 떨어져 있던 두 사람이 같은 내용을 고쳤을 때 생겨요.", "글에는 시퀀스 CRDT를, 단순한 항목에는 마지막에 쓴 값이 이기는 방식을 써요. 조심스럽게 다룬 예외도 몇 가지 있어요."],
  },
  {
    id: "lisbon-offsite", category: "회사", date: "2026-09-04", readTime: 4, author: sofia,
    title: "리스본에서 일주일을 보내며 배운 것",
    excerpt: "마흔 명, 하나의 로드맵, 슬라이드는 금지. 가을 워크숍 기록이에요.",
    image: { src: "/media/photos/lisbon-rooftops.jpg", alt: "강까지 이어지는 리스본의 테라코타 지붕" },
    body: ["일주일 동안 슬라이드를 쓰지 않았어요. 모든 세션은 글로 쓴 메모와 10분간의 조용한 읽기로 시작했어요.", "지금까지 한 워크숍 중 가장 알찼고, 그때 정한 로드맵은 이전 어느 때보다 잘 버티고 있어요."],
  },
  {
    id: "shared-views", category: "제품", date: "2026-08-28", readTime: 3, author: emma,
    title: "공유 보기가 나왔어요",
    excerpt: "필터를 저장하고 이름을 붙여 팀과 나눠 보세요. 모두가 같은 화면을, 늘 최신으로 볼 수 있어요.",
    image: { src: "/media/photos/home-office.jpg", alt: "나무 책상과 짙은 초록색 벽이 있는 서재" },
    body: ["저장된 필터는 올해 가장 많이 요청받은 기능이었어요. 이제 공유 보기가 됐어요. 보기에 이름을 붙이고 볼 사람을 고르면, 데이터가 바뀔 때마다 함께 갱신돼요."],
  },
  {
    id: "type-scale", category: "디자인", date: "2026-08-21", readTime: 6, author: jasmine,
    title: "후회하지 않을 글자 크기 체계 고르기",
    excerpt: "크기 일곱 개, 굵기 두 개, 그리고 제품이 커져도 이를 지키는 규칙이에요.",
    image: { src: "/media/photos/concert-hall.jpg", alt: "파란 하늘 아래 곡선형 스테인리스 패널로 덮인 콘서트홀" },
    body: ["글자 크기 체계는 약속이에요. 크기를 하나 더할 때마다 다음 결정이 어려워져요.", "크기 일곱 개와 굵기 두 개로 정하고, 각각 언제 쓸 수 있는지 적어 뒀어요."],
  },
  {
    id: "query-planner", category: "개발", date: "2026-08-14", readTime: 11, author: marcus,
    title: "검색을 열 배 빠르게 만들기",
    excerpt: "새 쿼리 플래너, 더 똑똑한 인덱스, 그리고 도중에 발견한 부끄러운 N+1 문제 하나예요.",
    image: { src: "/media/photos/mountain-ridges.jpg", alt: "따뜻한 저녁 하늘 아래 겹겹이 이어진 산등성이" },
    body: ["검색은 예전에 상위 5% 기준으로 400밀리초쯤 걸렸어요. 지금은 38밀리초예요.", "개선의 대부분은 플래너에서 나왔어요. 나머지는 애초에 쓰지 말았어야 할 코드를 지워서 얻었어요."],
  },
  {
    id: "remote-rituals", category: "회사", date: "2026-08-06", readTime: 5, author: nathan,
    title: "원격 팀을 가깝게 해 주는 습관",
    excerpt: "글로 쓰는 스탠드업, 금요일 데모, 그리고 절대 취소하지 않는 회의 하나예요.",
    image: { src: "/media/photos/sunroom.jpg", alt: "둥근 식탁과 화분, 세 면이 창으로 둘러싸인 선룸" },
    body: ["우리는 아홉 개 시간대에 걸쳐 일해요. 오래 살아남는 습관은 그 사실을 존중하는 습관이에요."],
  },
  {
    id: "usage-insights", category: "제품", date: "2026-07-30", readTime: 4, author: chloe,
    title: "모든 워크스페이스를 위한 사용 현황",
    excerpt: "팀이 어떤 기능을 자주 쓰는지, 어디서 막히는지, 이번 주에 무엇이 바뀌었는지 볼 수 있어요.",
    image: { src: "/media/photos/alpine-lake.jpg", alt: "해 질 녘 바위 봉우리가 비치는 고요한 고산 호수" },
    body: ["이제 관리자는 워크스페이스 설정에서 사용 현황을 열 수 있어요. 기능별 사용률과 시간에 따른 추세, 이메일로 받는 주간 요약을 볼 수 있어요."],
  },
  {
    id: "motion-rules", category: "디자인", date: "2026-07-22", readTime: 8, author: emma,
    title: "움직임은 꾸미지 말고 설명해야 해요",
    excerpt: "애니메이션 규칙은 이래요. 모든 움직임은 무언가가 어디서 왔고 어디로 갔는지 알려 줘야 해요.",
    image: { src: "/media/photos/terracotta-waves.jpg", alt: "파란 하늘로 솟은 물결무늬 테라코타 벽" },
    body: ["애니메이션이 무엇을 설명하는지 말할 수 없다면 없애요. 이 규칙 하나로 움직임 코드의 3분의 1이 사라졌어요."],
  },
  {
    id: "postgres-upgrade", category: "개발", date: "2026-07-15", readTime: 10, author: daniel,
    title: "중단 없이 Postgres 업그레이드하기",
    excerpt: "논리 복제, 운영 환경 사본으로 한 예행연습, 그리고 4초 만에 끝난 전환이에요.",
    image: { src: "/media/photos/coastline.jpg", alt: "초록 절벽 아래 해변으로 파도가 밀려오는 긴 해안선" },
    body: ["고객이 계속 일하는 동안 2테라바이트를 새 메이저 버전으로 옮겼어요. 그 실행 절차를 공유해요."],
  },
  {
    id: "series-b", category: "회사", date: "2026-07-08", readTime: 3, author: sofia,
    title: "우리의 다음 장",
    excerpt: "팀을 위한 가장 차분한 도구를 만들려고 시리즈 B 투자를 받았어요. 무엇이 바뀌고 무엇이 그대로인지 알려 드려요.",
    image: { src: "/media/photos/sea-at-dusk.jpg", alt: "수평선에 낮은 섬이 보이는 해 질 녘의 고요한 바다" },
    body: ["제품 가격은 그대로예요. 팀은 두 배로 늘어요. 로드맵은 더 빨라져요."],
  },
  {
    id: "keyboard-first", category: "제품", date: "2026-06-30", readTime: 5, author: marcus,
    title: "모든 기능에 키보드 단축키를",
    excerpt: "어디서든 물음표를 누르면 모든 단축키를 볼 수 있고, 직접 바꿀 수도 있어요.",
    image: { src: "/media/photos/reading-chair.jpg", alt: "짙은 초록색 방의 회색 안락의자와 발받침, 니트 담요" },
    body: ["이제 제품의 모든 동작에 단축키가 있고, 설정에서 원하는 대로 바꿀 수 있어요."],
  },
  {
    id: "color-tokens", category: "디자인", date: "2026-06-24", readTime: 6, author: jasmine,
    title: "색 토큰은 색상이 아니라 역할로 이름 붙여요",
    excerpt: "surface와 border가 gray-100, gray-200보다 나은 이유와, 파일 400개를 옮긴 과정이에요.",
    image: { src: "/media/photos/pool-house.jpg", alt: "맑은 하늘 아래 긴 수영장 옆에 선 현대식 유리 집" },
    body: ["의미 기반 토큰 덕분에 다크 모드, 고대비, 브랜드 테마가 하나의 컴포넌트 코드를 함께 써요."],
  },
];

```

### blog-grid.module.css

```css
.root { width: 100%; min-width: 0; container: blog / inline-size; background: var(--background); color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); }
.inner { position: relative; max-width: 1180px; margin: 0 auto; padding: var(--space-16) var(--space-6); }
.index { display: grid; gap: var(--space-8); }

.header { display: grid; gap: var(--space-3); max-width: 600px; }
.title { margin: 0; font-family: var(--font-display); font-size: clamp(1.75rem, 1rem + 3cqi, var(--text-4xl)); font-weight: 500; letter-spacing: var(--tracking-display); line-height: var(--leading-display); }
.description { margin: 0; color: var(--text-secondary); font-size: var(--text-base); line-height: var(--leading-body); text-wrap: pretty; }

.filters { display: flex; gap: 2px; margin: 0 calc(var(--space-6) * -1); overflow-x: auto; padding: 0 var(--space-6); scrollbar-width: none; }
.filters::-webkit-scrollbar { display: none; }
.filter { position: relative; isolation: isolate; flex: none; height: 36px; border: 0; border-radius: var(--radius-pill); padding: 0 var(--space-4); background: none; color: var(--text-secondary); font: inherit; font-size: var(--text-sm); font-weight: 500; white-space: nowrap; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard); }
.filter[aria-pressed="true"] { color: var(--background); }
@media (hover: hover) and (pointer: fine) { .filter[aria-pressed="false"]:hover { color: var(--foreground); } }
.filterHighlight { position: absolute; z-index: -1; inset: 0; border-radius: inherit; background: var(--foreground); }

/* Page and pagination share one measured box, so a shorter filter or page closes its gap on a spring. It only clips while it moves. */
.clip { min-width: 0; }
.clip[data-clip] { overflow: hidden; }
.stack { display: grid; gap: var(--space-8); }
.page { display: grid; gap: var(--space-12); }
.grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-12) var(--space-6); }

.card, .featured { display: grid; align-content: start; gap: var(--space-4); border-radius: 22px; color: inherit; text-decoration: none; -webkit-tap-highlight-color: transparent; }
.featured { grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr); align-items: center; gap: var(--space-10); }
.imageFrame { position: relative; overflow: hidden; aspect-ratio: 3 / 2; border-radius: 20px; background: var(--surface-muted); }
.featured .imageFrame { aspect-ratio: 16 / 10; border-radius: 26px; }
.image { display: block; width: 100%; height: 100%; object-fit: cover; transition: transform var(--duration-considered) var(--ease-standard); }
.cardBody { display: grid; align-content: start; gap: var(--space-2); }
.featured .cardBody { gap: var(--space-3); }
.meta { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; color: var(--text-muted); font-size: var(--text-xs); }
.meta span:first-child { color: var(--foreground); font-weight: 500; }
.cardTitle { margin: 0; font-size: var(--text-lg); font-weight: 500; line-height: 1.3; text-wrap: balance; transition: color var(--duration-fast) var(--ease-standard); }
.featuredTitle { margin: 0; font-family: var(--font-display); font-size: clamp(var(--text-2xl), 1rem + 2.2cqi, var(--text-3xl)); font-weight: 500; letter-spacing: var(--tracking-display); line-height: var(--leading-display); text-wrap: balance; }
.excerpt { display: -webkit-box; margin: 0; overflow: hidden; color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.5; text-wrap: pretty; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
.featured .excerpt { font-size: var(--text-base); -webkit-line-clamp: 3; }
.byline { display: flex; min-width: 0; align-items: center; gap: var(--space-2); margin-top: var(--space-2); font-size: var(--text-sm); }
.avatar { width: 24px; height: 24px; flex: none; border-radius: 50%; background: var(--surface-muted); object-fit: cover; }
.authorName { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.readTime { flex: none; color: var(--text-muted); }
.readTime::before { content: "·"; margin-right: var(--space-2); }
.card:active .imageFrame, .featured:active .imageFrame { transform: scale(.99); }
.imageFrame { transition: transform var(--duration-fast) var(--ease-standard); }
@media (hover: hover) and (pointer: fine) {
  .card:hover .image, .featured:hover .image { transform: scale(1.03); }
  .card:hover .cardTitle { color: var(--text-secondary); }
}
.empty { margin: 0; padding: var(--space-16) 0; color: var(--text-secondary); text-align: center; }

.pagination { position: relative; display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); padding-top: var(--space-6); }
.pagination::before { content: ""; position: absolute; top: 0; right: 0; left: 0; height: 1px; background: var(--border); }
.pageNumbers { display: flex; gap: 2px; }
.pageNumber, .pageStep { position: relative; isolation: isolate; display: inline-flex; height: 36px; min-width: 36px; align-items: center; justify-content: center; gap: 6px; border: 0; border-radius: var(--radius-pill); padding: 0 var(--space-3); background: none; color: var(--text-secondary); font: inherit; font-size: var(--text-sm); font-weight: 500; font-variant-numeric: tabular-nums; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); }
.pageNumber { padding: 0; }
.pageNumber[aria-current="page"] { color: var(--foreground); }
.pageHighlight { position: absolute; z-index: -1; inset: 0; border-radius: inherit; background: var(--surface-muted); }
.pageStep:disabled { color: var(--text-muted); opacity: .5; cursor: default; }
.pageStep:not(:disabled):active { transform: scale(.97); }
@media (hover: hover) and (pointer: fine) { .pageNumber:hover, .pageStep:not(:disabled):hover { color: var(--foreground); } }

.reader { display: grid; max-width: 760px; gap: var(--space-8); margin: 0 auto; }
.back { display: inline-flex; width: fit-content; height: 36px; align-items: center; gap: 6px; border: 0; border-radius: var(--radius-pill); padding: 0 var(--space-3) 0 var(--space-2); margin-left: calc(var(--space-2) * -1); background: none; color: var(--text-secondary); font: inherit; font-size: var(--text-sm); font-weight: 500; cursor: pointer; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }
@media (hover: hover) and (pointer: fine) { .back:hover { background: var(--surface-muted); color: var(--foreground); } }
.readerHead { display: grid; gap: var(--space-3); }
.readerTitle { margin: 0; font-family: var(--font-display); font-size: clamp(var(--text-2xl), 1rem + 3.4cqi, var(--text-4xl)); font-weight: 500; letter-spacing: var(--tracking-display); line-height: var(--leading-display); text-wrap: balance; }
.readerImage { overflow: hidden; aspect-ratio: 16 / 9; border-radius: 26px; background: var(--surface-muted); }
.readerBody { display: grid; gap: var(--space-5); }
.readerBody p { margin: 0; color: var(--text-secondary); font-size: var(--text-lg); line-height: 1.65; text-wrap: pretty; }
.readerBody .lead { color: var(--foreground); font-size: var(--text-xl); line-height: 1.5; }

@container blog (max-width: 900px) {
  .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .featured { gap: var(--space-6); }
}
@container blog (max-width: 680px) {
  .inner { padding: var(--space-12) var(--space-4); }
  .filters { margin: 0 calc(var(--space-4) * -1); padding: 0 var(--space-4); }
  .featured { grid-template-columns: minmax(0, 1fr); gap: var(--space-4); }
  .featured .imageFrame { aspect-ratio: 3 / 2; border-radius: 20px; }
  .page { gap: var(--space-10); }
  .grid { gap: var(--space-10) var(--space-4); }
  .readerBody p { font-size: var(--text-base); }
  .readerBody .lead { font-size: var(--text-lg); }
}
@container blog (max-width: 540px) {
  .grid { grid-template-columns: minmax(0, 1fr); }
  .pageStep span { display: none; }
  .pageStep { padding: 0; }
}

@media (prefers-reduced-motion: reduce) {
  .image, .imageFrame { transition: none; }
  .card:hover .image, .featured:hover .image { transform: none; }
}

```

### blog-grid.tsx

```tsx
"use client";
/* eslint-disable @next/next/no-img-element -- plain images keep the block portable outside Next.js. */

import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import type { MouseEvent, ReactNode } from "react";
import { AnimatePresence, LayoutGroup, animate, motion, useMotionValue } from "motion/react";
import { useReducedMotion } from "../../lib/use-reduced-motion";
import { motionTokens } from "../../lib/motion-tokens";
import { blogCategories, blogPosts } from "./blog-grid-data";
import type { BlogPost } from "./blog-grid-data";
import styles from "./blog-grid.module.css";
import { UiIcon } from "../../theme/ui-icon";
import { playUiSound } from "../../sound/play";

export type { BlogAuthor, BlogPost } from "./blog-grid-data";

export interface BlogGridProps {
  title?: string;
  description?: string;
  posts?: BlogPost[];
  /** Category filter labels, in order. "전체" is added in front. */
  categories?: string[];
  /** Active category, or "전체" (controlled). */
  category?: string;
  defaultCategory?: string;
  onCategoryChange?: (category: string) => void;
  /** One based page (controlled). */
  page?: number;
  defaultPage?: number;
  onPageChange?: (page: number) => void;
  /** Cards per page below the featured post. Defaults to 6. */
  pageSize?: number;
  /** Shows the newest post of the current filter as a large card on page one. Defaults to true. */
  showFeatured?: boolean;
  /** Link for each post. When set, cards render as links and the in-place reader is off. */
  getHref?: (post: BlogPost) => string;
  /** Called when a post opens, from a link or the in-place reader. */
  onPostOpen?: (post: BlogPost) => void;
  className?: string;
}

const smooth = motionTokens.spring.smooth;
const morph = motionTokens.spring.morph;
const standard = [...motionTokens.ease.standard] as [number, number, number, number];
const dateFormat = new Intl.DateTimeFormat("ko-KR", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
const formatDate = (iso: string) => dateFormat.format(new Date(`${iso}T00:00:00Z`));

function useControllable<T>(value: T | undefined, initial: T, onChange?: (next: T) => void) {
  const [inner, setInner] = useState(initial);
  const current = value !== undefined ? value : inner;
  const set = (next: T) => { if (value === undefined) setInner(next); onChange?.(next); };
  return [current, set] as const;
}

/** 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. */
function AutoHeight({ changeKey, reduced, className, children }: { changeKey: string; reduced: boolean; className?: string; children: ReactNode }) {
  const clipRef = useRef<HTMLDivElement>(null);
  const contentRef = useRef<HTMLDivElement>(null);
  const height = useMotionValue<number | "auto">("auto");
  const lastKey = useRef(changeKey), armedUntil = useRef(0);
  useLayoutEffect(() => {
    if (lastKey.current === changeKey) return;
    lastKey.current = changeKey;
    armedUntil.current = performance.now() + 900;
  }, [changeKey]);
  useEffect(() => {
    const clip = clipRef.current, content = contentRef.current;
    if (!clip || !content || typeof ResizeObserver === "undefined") return;
    let controls: ReturnType<typeof animate> | undefined;
    const observer = new ResizeObserver(() => {
      const next = content.offsetHeight;
      controls?.stop();
      if (!next || reduced || height.get() === "auto" || performance.now() > armedUntil.current) { height.jump(next || "auto"); delete clip.dataset.clip; return; }
      clip.dataset.clip = "";
      controls = animate(height, next, { ...smooth, onComplete: () => { delete clip.dataset.clip; } });
    });
    observer.observe(content);
    return () => { observer.disconnect(); controls?.stop(); };
  }, [height, reduced]);
  return <motion.div ref={clipRef} className={styles.clip} style={{ height }}>
    <div ref={contentRef} className={className}>{children}</div>
  </motion.div>;
}

function Meta({ post }: { post: BlogPost }) {
  return <p className={styles.meta}><span>{post.category}</span><span aria-hidden="true">·</span><time dateTime={post.date}>{formatDate(post.date)}</time></p>;
}

function Byline({ post }: { post: BlogPost }) {
  return <div className={styles.byline}>
    {post.author.avatar ? <img className={styles.avatar} src={post.author.avatar} alt="" width={24} height={24} loading="lazy" /> : <span className={styles.avatar} aria-hidden="true" />}
    <span className={styles.authorName}>{post.author.name}</span>
    <span className={styles.readTime}>읽는 데 {post.readTime}분</span>
  </div>;
}

export function BlogGrid({
  title = "저널",
  description = "팀이 전하는 제품 소식, 디자인 노트, 엔지니어링 이야기를 모았어요.",
  posts = blogPosts,
  categories = blogCategories,
  category: categoryProp,
  defaultCategory = "전체",
  onCategoryChange,
  page: pageProp,
  defaultPage = 1,
  onPageChange,
  pageSize = 6,
  showFeatured = true,
  getHref,
  onPostOpen,
  className,
}: BlogGridProps) {
  const reduced = useReducedMotion();
  const uid = useId();
  const rootRef = useRef<HTMLElement>(null);
  const [category, setCategoryState] = useControllable(categoryProp, defaultCategory, onCategoryChange);
  const [page, setPageState] = useControllable(pageProp, defaultPage, onPageChange);
  const [direction, setDirection] = useState(0);
  const [reading, setReading] = useState<BlogPost | null>(null);
  /** The post the reader just closed. Its image morphs back into its card while the rest of the index fades in around it. */
  const [returning, setReturning] = useState<string | null>(null);
  const lastOpened = useRef<string | null>(null);

  const sorted = [...posts].sort((a, b) => b.date.localeCompare(a.date));
  const filtered = category === "전체" ? sorted : sorted.filter(post => post.category === category);
  const featured = showFeatured && filtered.length > 1 ? (filtered.find(post => post.featured) ?? filtered[0]) : null;
  const rest = featured ? filtered.filter(post => post !== featured) : filtered;
  const pageCount = Math.max(1, Math.ceil(rest.length / pageSize));
  const safePage = Math.min(Math.max(1, page), pageCount);
  const pagePosts = rest.slice((safePage - 1) * pageSize, safePage * pageSize);

  function scrollToTop() {
    const node = rootRef.current;
    if (node && node.getBoundingClientRect().top < 0) node.scrollIntoView({ behavior: reduced ? "auto" : "smooth", block: "start" });
  }
  function setCategory(next: string) {
    if (next === category) return;
    playUiSound("tick");
    setDirection(0);
    setReturning(null);
    setCategoryState(next);
    setPageState(1);
  }
  function setPage(next: number) {
    if (next === safePage || next < 1 || next > pageCount) return;
    playUiSound("tick");
    setDirection(next > safePage ? 1 : -1);
    setReturning(null);
    setPageState(next);
    scrollToTop();
  }
  function open(post: BlogPost, event: MouseEvent) {
    playUiSound("tick");
    onPostOpen?.(post);
    if (getHref) return;
    event.preventDefault();
    lastOpened.current = post.id;
    setReading(post);
    scrollToTop();
  }
  function close() {
    playUiSound("tick");
    setReturning(lastOpened.current);
    setReading(null);
    requestAnimationFrame(() => {
      const card = rootRef.current?.querySelector<HTMLElement>(`[data-post="${lastOpened.current}"]`);
      card?.focus({ preventScroll: true });
      card?.scrollIntoView({ block: "nearest", behavior: reduced ? "auto" : "smooth" });
    });
  }

  const renderCard = (post: BlogPost, isFeatured = false) => {
    const href = getHref?.(post);
    const inner = <>
      {post.image && <motion.div layoutId={reduced ? undefined : `${uid}-image-${post.id}`} transition={morph} className={styles.imageFrame}>
        <img className={styles.image} src={post.image.src} alt={post.image.alt} loading={isFeatured ? "eager" : "lazy"} />
      </motion.div>}
      <div className={styles.cardBody}>
        <Meta post={post} />
        <h3 className={isFeatured ? styles.featuredTitle : styles.cardTitle}>{post.title}</h3>
        <p className={styles.excerpt}>{post.excerpt}</p>
        <Byline post={post} />
      </div>
    </>;
    const className = isFeatured ? styles.featured : styles.card;
    const fades = returning !== null && (reduced || post.id !== returning);
    return <motion.a key={post.id} className={className} href={href ?? `#${post.id}`} data-post={post.id} onClick={event => open(post, event)} {...enterFade(fades)}>{inner}</motion.a>;
  };

  /** 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. */
  const enterFade = (on: boolean) => ({ initial: on ? { opacity: 0 } : false as const, animate: { opacity: 1 }, transition: { duration: motionTokens.duration.standard, ease: standard } });
  const back = returning !== null;
  const tabs = ["전체", ...categories];
  const pageKey = `${category}-${safePage}`;

  return <section ref={rootRef} className={[styles.root, className].filter(Boolean).join(" ")} aria-label={title}>
    <LayoutGroup id={uid}>
      <div className={styles.inner}>
        <AnimatePresence mode="popLayout" initial={false}>
          {reading ? <motion.article key="reader" className={styles.reader} exit={{ opacity: 0, transition: { duration: motionTokens.duration.exit, ease: standard } }} aria-labelledby={`${uid}-reader-title`}>
            <motion.button type="button" className={styles.back} onClick={close} autoFocus {...enterFade(true)}><UiIcon iconRole="arrow-left" size={16} aria-hidden="true" />모든 글 보기</motion.button>
            <motion.header className={styles.readerHead} {...enterFade(true)}>
              <Meta post={reading} />
              <h2 id={`${uid}-reader-title`} className={styles.readerTitle}>{reading.title}</h2>
              <Byline post={reading} />
            </motion.header>
            {reading.image && <motion.div layoutId={reduced ? undefined : `${uid}-image-${reading.id}`} className={styles.readerImage} {...(reduced ? enterFade(true) : { transition: morph })}>
              <img className={styles.image} src={reading.image.src} alt={reading.image.alt} />
            </motion.div>}
            <motion.div className={styles.readerBody} initial={{ opacity: 0, y: reduced ? 0 : 12 }} animate={{ opacity: 1, y: 0 }} transition={{ ...smooth, delay: reduced ? 0 : motionTokens.duration.instant }}>
              <p className={styles.lead}>{reading.excerpt}</p>
              {(reading.body ?? []).map(paragraph => <p key={paragraph}>{paragraph}</p>)}
            </motion.div>
          </motion.article> : <motion.div key="index" className={styles.index} exit={{ opacity: 0, transition: { duration: motionTokens.duration.exit, ease: standard } }}>
            <motion.header className={styles.header} {...enterFade(back)}>
              <h2 className={styles.title}>{title}</h2>
              {description && <p className={styles.description}>{description}</p>}
            </motion.header>

            <motion.div className={styles.filters} role="group" aria-label="카테고리별로 거르기" {...enterFade(back)}>
              {tabs.map(tab => <button key={tab} type="button" className={styles.filter} aria-pressed={tab === category} onClick={() => setCategory(tab)}>
                {tab === category && <motion.span layoutId={`${uid}-filter`} className={styles.filterHighlight} transition={reduced ? { duration: 0 } : morph} />}
                <span>{tab}</span>
              </button>)}
            </motion.div>

            <AutoHeight changeKey={`${pageKey}-${pageCount}`} reduced={Boolean(reduced)} className={styles.stack}>
            <AnimatePresence mode="wait" initial={false} custom={direction}>
              <motion.div
                key={pageKey}
                className={styles.page}
                initial={reduced ? { opacity: 0 } : { opacity: 0, x: direction * 24, y: direction ? 0 : 10 }}
                animate={{ opacity: 1, x: 0, y: 0 }}
                exit={reduced ? { opacity: 0 } : { opacity: 0, x: direction * -24, transition: { duration: motionTokens.duration.exit, ease: standard } }}
                transition={{ ...smooth, opacity: { duration: motionTokens.duration.standard } }}
              >
                {featured && safePage === 1 && renderCard(featured, true)}
                {pagePosts.length > 0 && <div className={styles.grid}>{pagePosts.map(post => renderCard(post))}</div>}
                {filtered.length === 0 && <p className={styles.empty}>{category} 카테고리에는 아직 글이 없어요.</p>}
              </motion.div>
            </AnimatePresence>

            <AnimatePresence initial={back}>
            {pageCount > 1 && <motion.nav key="pagination" className={styles.pagination} aria-label="페이지 이동" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: { duration: motionTokens.duration.exit, ease: standard } }} transition={{ duration: motionTokens.duration.standard, ease: standard }}>
              <button type="button" className={styles.pageStep} onClick={() => setPage(safePage - 1)} disabled={safePage === 1} aria-label="이전 페이지"><UiIcon iconRole="chevron-left" size={16} aria-hidden="true" /><span>이전</span></button>
              <div className={styles.pageNumbers}>
                {Array.from({ length: pageCount }, (_, index) => index + 1).map(number => <button key={number} type="button" className={styles.pageNumber} aria-current={number === safePage ? "page" : undefined} aria-label={`${number}페이지`} onClick={() => setPage(number)}>
                  {number === safePage && <motion.span layoutId={`${uid}-page`} className={styles.pageHighlight} transition={reduced ? { duration: 0 } : morph} />}
                  <span>{number}</span>
                </button>)}
              </div>
              <button type="button" className={styles.pageStep} onClick={() => setPage(safePage + 1)} disabled={safePage === pageCount} aria-label="다음 페이지"><span>다음</span><UiIcon iconRole="chevron-right" size={16} aria-hidden="true" /></button>
            </motion.nav>}
            </AnimatePresence>
            </AutoHeight>
          </motion.div>}
        </AnimatePresence>
      </div>
    </LayoutGroup>
  </section>;
}

/** Preview: the blog index with the in-place reader. */
export function BlogGridBlock() {
  return <BlogGrid />;
}

export default BlogGridBlock;

```
