# Blog archive workspace

> A data-driven blog archive with search, topic filters, sorting and accessible empty states.

A shadcn/ui block from the [@neumorphism-ui](https://neumorphism-ui.andongmin.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/neumorphism-ui/template-blog).

- Page, with a live preview: https://shadcn.exchange/r/neumorphism-ui/template-blog
- Screenshot: https://shadcn.exchange/shot/neumorphism-ui/template-blog/light.png
- Registry item: https://neumorphism-ui.andongmin.com/r/template-blog.json
- Categories: template
- Listed: 2026-11-01, source last changed 2026-11-01

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/neumorphism-ui/template-blog.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- Registry items: `@neumorphism-ui/button`, `@neumorphism-ui/input`, `@neumorphism-ui/select`, `@neumorphism-ui/utils`

## Files

### `src/components/blocks/blog.tsx`

```tsx
"use client";

import * as React from "react";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectItem } from "@/components/ui/select";
import { cn } from "@/lib/utils";

export type BlogLocale = "ko" | "en" | "ja" | "zh";

export type BlogPost = {
  slug: string;
  title: string;
  summary: string;
  topic: string;
  publishedAt: string;
  publishedLabel?: string;
  readTimeMinutes?: number;
};

export interface BlogProps {
  posts: readonly BlogPost[];
  basePath?: string;
  locale?: BlogLocale;
  className?: string;
}

const copy = {
  ko: {
    title: "최근 글",
    intro: "실제 작업에서 얻은 제품·디자인·프런트엔드 메모를 모아봅니다.",
    search: "글 검색",
    placeholder: "글 검색…",
    clear: "검색어 지우기",
    filter: "주제로 필터",
    all: "전체",
    sort: "정렬",
    newest: "최신순",
    oldest: "오래된 순",
    shortest: "짧은 읽기순",
    post: "개 글",
    posts: "개 글",
    noResults: "조건에 맞는 글이 없습니다.",
    reset: "필터 초기화",
    read: "분",
  },
  en: {
    title: "Latest posts",
    intro: "Practical notes from product, design and front-end work.",
    search: "Search posts",
    placeholder: "Search posts…",
    clear: "Clear search",
    filter: "Filter posts by topic",
    all: "All",
    sort: "Sort posts",
    newest: "Newest first",
    oldest: "Oldest first",
    shortest: "Shortest read",
    post: "post",
    posts: "posts",
    noResults: "No posts match the current filters.",
    reset: "Reset filters",
    read: "min read",
  },
  ja: {
    title: "最新の記事",
    intro: "プロダクト、デザイン、フロントエンドの実務から得たメモです。",
    search: "記事を検索",
    placeholder: "記事を検索…",
    clear: "検索をクリア",
    filter: "トピックで絞り込む",
    all: "すべて",
    sort: "並び順",
    newest: "新しい順",
    oldest: "古い順",
    shortest: "短い順",
    post: "件",
    posts: "件",
    noResults: "条件に一致する記事はありません。",
    reset: "フィルターをリセット",
    read: "分で読めます",
  },
  zh: {
    title: "最新文章",
    intro: "来自产品、设计与前端工作的实用笔记。",
    search: "搜索文章",
    placeholder: "搜索文章…",
    clear: "清除搜索",
    filter: "按主题筛选",
    all: "全部",
    sort: "排序",
    newest: "最新优先",
    oldest: "最早优先",
    shortest: "阅读时间最短",
    post: "篇",
    posts: "篇",
    noResults: "没有符合当前筛选条件的文章。",
    reset: "重置筛选",
    read: "分钟阅读",
  },
} as const;

function postHref(basePath: string, slug: string) {
  return basePath.replace(/\/$/, "") + "/" + slug;
}

export function Blog({
  posts,
  basePath = "/blog",
  locale = "en",
  className,
}: BlogProps) {
  const t = copy[locale];
  const [query, setQuery] = React.useState("");
  const [topic, setTopic] = React.useState<string | null>(null);
  const [sort, setSort] = React.useState<"newest" | "oldest" | "shortest">("newest");
  // Server-rendered filters cannot accept edits before the client owns their state.
  const [hydrated, setHydrated] = React.useState(false);
  React.useEffect(() => setHydrated(true), []);
  const searchId = React.useId();
  const searchRef = React.useRef<HTMLInputElement>(null);

  const topics = React.useMemo(
    () => Array.from(new Set(posts.map((post) => post.topic))),
    [posts],
  );

  React.useEffect(() => {
    if (topic !== null && !topics.includes(topic)) setTopic(null);
  }, [topic, topics]);

  const filteredPosts = React.useMemo(() => {
    const normalizedQuery = query.trim().toLowerCase();
    return posts
      .filter((post) => {
        const matchesTopic = topic === null || post.topic === topic;
        const text = [post.title, post.summary, post.topic].join(" ").toLowerCase();
        return matchesTopic && text.includes(normalizedQuery);
      })
      .sort((a, b) => {
        if (sort === "shortest") {
          return (a.readTimeMinutes ?? Number.MAX_SAFE_INTEGER) -
            (b.readTimeMinutes ?? Number.MAX_SAFE_INTEGER);
        }
        return sort === "oldest"
          ? a.publishedAt.localeCompare(b.publishedAt)
          : b.publishedAt.localeCompare(a.publishedAt);
      });
  }, [posts, query, sort, topic]);

  function resetFilters() {
    setQuery("");
    setTopic(null);
    setSort("newest");
    searchRef.current?.focus();
  }

  return (
    <section
      data-slot="blog"
      className={cn("mx-auto grid w-full max-w-3xl gap-7 text-[var(--foreground)]", className)}
    >
      <header className="grid gap-3">
        <h2 className="text-3xl font-semibold tracking-tight sm:text-4xl">{t.title}</h2>
        <p className="max-w-xl text-sm leading-relaxed text-[var(--muted-foreground)]">
          {t.intro}
        </p>
      </header>

      <section aria-busy={!hydrated} className="grid gap-4 rounded-[var(--neu-radius-surface)] border border-[color:var(--neu-edge)] bg-transparent py-4 shadow-none">
        <div className="grid gap-2">
          <label htmlFor={searchId} className="text-xs font-semibold">
            {t.search}
          </label>
          <div className="flex gap-2">
            <Input
              ref={searchRef}
              id={searchId}
              type="search"
              role="searchbox"
              disabled={!hydrated}
              value={query}
              onChange={(event) => setQuery(event.target.value)}
              placeholder={t.placeholder}
            />
            {query ? (
              <Button type="button" variant="soft" disabled={!hydrated} onClick={() => { setQuery(""); searchRef.current?.focus(); }}>
                {t.clear}
              </Button>
            ) : null}
          </div>
        </div>

        <div className="flex flex-wrap items-end justify-between gap-4">
          <div role="group" aria-label={t.filter} className="flex flex-wrap gap-2">
            <Button
              type="button"
              size="sm"
              disabled={!hydrated}
              variant={topic === null ? "primary" : "ghost"}
              aria-pressed={topic === null}
              onClick={() => setTopic(null)}
            >
              {t.all}
            </Button>
            {topics.map((item) => (
              <Button
                key={item}
                type="button"
                size="sm"
                disabled={!hydrated}
                variant={topic === item ? "primary" : "ghost"}
                aria-pressed={topic === item}
                onClick={() => setTopic(item)}
              >
                {item}
              </Button>
            ))}
          </div>

          <label className="grid min-w-44 gap-1 text-xs font-semibold">
            {t.sort}
            <Select disabled={!hydrated} value={sort} onChange={(event) => setSort(event.target.value as typeof sort)}>
              <SelectItem value="newest">{t.newest}</SelectItem>
              <SelectItem value="oldest">{t.oldest}</SelectItem>
              <SelectItem value="shortest">{t.shortest}</SelectItem>
            </Select>
          </label>
        </div>

        <p role="status" className="text-xs text-[var(--muted-foreground)]">
          {filteredPosts.length} {filteredPosts.length === 1 ? t.post : t.posts}
        </p>
      </section>

      {filteredPosts.length ? (
        <ol className="grid gap-3">
          {filteredPosts.map((post) => (
            <li key={post.slug}>
              <article className="grid gap-3 rounded-[var(--neu-radius-surface)] border border-[color:var(--neu-edge)] bg-[var(--neu-surface)] p-5 [box-shadow:var(--neu-shadow-raised-sm)]">
                <div className="flex flex-wrap items-center justify-between gap-3 text-xs text-[var(--muted-foreground)]">
                  <time dateTime={post.publishedAt}>{post.publishedLabel ?? post.publishedAt}</time>
                  <span>{post.topic}{post.readTimeMinutes ? " · " + post.readTimeMinutes + " " + t.read : ""}</span>
                </div>
                <h3 className="text-xl font-semibold">
                  <a
                    href={postHref(basePath, post.slug)}
                    className="rounded-sm underline-offset-4 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-[var(--ring)]"
                  >
                    {post.title}
                  </a>
                </h3>
                <p className="text-sm leading-relaxed text-[var(--muted-foreground)]">{post.summary}</p>
              </article>
            </li>
          ))}
        </ol>
      ) : (
        <div className="grid justify-items-center gap-4 rounded-[var(--neu-radius-surface)] border border-dashed border-[var(--border)] py-10">
          <p className="text-sm text-[var(--muted-foreground)]">{t.noResults}</p>
          <Button type="button" disabled={!hydrated} onClick={resetFilters}>{t.reset}</Button>
        </div>
      )}
    </section>
  );
}

const examplePosts = {
  "en": [
    {
      "slug": "draft-after-failed-save",
      "title": "Recover a draft after a failed save",
      "summary": "Retain the edited title and body, report the error, and retry the save.",
      "topic": "Product",
      "publishedAt": "2026-09-18",
      "readTimeMinutes": 4
    },
    {
      "slug": "dialog-keyboard-focus",
      "title": "Return keyboard focus after closing a dialog",
      "summary": "Check Escape, the close action and focus returning to the button that opened the dialog.",
      "topic": "Engineering",
      "publishedAt": "2026-09-10",
      "readTimeMinutes": 6
    },
    {
      "slug": "compact-control-icons",
      "title": "Align icons in compact controls",
      "summary": "Use the same icon box for small buttons, menu indicators and disclosure controls.",
      "topic": "Design",
      "publishedAt": "2026-08-29",
      "readTimeMinutes": 5
    }
  ],
  "ko": [
    {
      "slug": "draft-after-failed-save",
      "title": "저장 실패 후 초안 복구하기",
      "summary": "수정한 제목과 본문을 유지하고 오류를 표시한 뒤 저장을 다시 시도합니다.",
      "topic": "제품",
      "publishedAt": "2026-09-18",
      "readTimeMinutes": 4
    },
    {
      "slug": "dialog-keyboard-focus",
      "title": "대화상자를 닫은 뒤 키보드 포커스 돌려주기",
      "summary": "Escape와 닫기 동작을 확인하고 대화상자를 연 버튼으로 포커스를 돌려줍니다.",
      "topic": "개발",
      "publishedAt": "2026-09-10",
      "readTimeMinutes": 6
    },
    {
      "slug": "compact-control-icons",
      "title": "작은 컨트롤의 아이콘 정렬하기",
      "summary": "작은 버튼, 메뉴 표시, 펼침 컨트롤에 같은 크기의 아이콘 영역을 사용합니다.",
      "topic": "디자인",
      "publishedAt": "2026-08-29",
      "readTimeMinutes": 5
    }
  ],
  "ja": [
    {
      "slug": "draft-after-failed-save",
      "title": "保存失敗後の下書きを復元する",
      "summary": "編集したタイトルと本文を保持し、エラーを表示して保存を再試行します。",
      "topic": "プロダクト",
      "publishedAt": "2026-09-18",
      "readTimeMinutes": 4
    },
    {
      "slug": "dialog-keyboard-focus",
      "title": "ダイアログを閉じた後にフォーカスを戻す",
      "summary": "Escape、閉じる操作、開いたボタンへのフォーカス復帰を確認します。",
      "topic": "開発",
      "publishedAt": "2026-09-10",
      "readTimeMinutes": 6
    },
    {
      "slug": "compact-control-icons",
      "title": "小さなコントロールのアイコンを揃える",
      "summary": "小さなボタン、メニューの印、開閉コントロールで同じアイコン領域を使います。",
      "topic": "デザイン",
      "publishedAt": "2026-08-29",
      "readTimeMinutes": 5
    }
  ],
  "zh": [
    {
      "slug": "draft-after-failed-save",
      "title": "保存失败后恢复草稿",
      "summary": "保留编辑过的标题和正文，显示错误，然后重新保存。",
      "topic": "产品",
      "publishedAt": "2026-09-18",
      "readTimeMinutes": 4
    },
    {
      "slug": "dialog-keyboard-focus",
      "title": "关闭对话框后恢复键盘焦点",
      "summary": "检查 Escape、关闭操作以及焦点是否返回打开对话框的按钮。",
      "topic": "开发",
      "publishedAt": "2026-09-10",
      "readTimeMinutes": 6
    },
    {
      "slug": "compact-control-icons",
      "title": "对齐小型控件中的图标",
      "summary": "为小按钮、菜单标记和展开控件使用相同尺寸的图标区域。",
      "topic": "设计",
      "publishedAt": "2026-08-29",
      "readTimeMinutes": 5
    }
  ]
} satisfies Record<BlogLocale, BlogPost[]>;

export function BlogExample({ locale = "en" }: { locale?: BlogLocale }) {
  return <Blog posts={examplePosts[locale]} locale={locale} basePath="#post" />;
}
```
