Blog archive
Find articles by title or topic, then sort by date.
Template preview
template-blogLatest posts
Practical notes from product, design and front-end work.
3 posts
- Product · 4 min read
Recover a draft after a failed save
Retain the edited title and body, report the error, and retry the save.
- Engineering · 6 min read
Return keyboard focus after closing a dialog
Check Escape, the close action and focus returning to the button that opened the dialog.
- Design · 5 min read
Align icons in compact controls
Use the same icon box for small buttons, menu indicators and disclosure controls.
Install
Set up the base theme and Pretendard once using the installation guide. Adding these templates does not overwrite your theme, global CSS or page routes.
npx shadcn@latest add @neumorphism-ui/template-blogUsage
"use client";
import { Blog, type BlogPost } from "@/components/blocks/blog";
export function Example({ posts }: { posts: BlogPost[] }) {
return <Blog posts={posts} locale="en" basePath="/blog" />;
}Connect your application
Supply posts and basePath from your application. Search, topic and sort are local UI state; your application owns article routes and content loading.
Installed source
@components/blocks/blog.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" />;
}