블로그 글 상세
제목·요약·발행 메타데이터·섹션·목록을 렌더링하는 정적 article 템플릿.
Design
Why small interfaces age better
A practical case for fewer controls, clearer defaults, and less maintenance.
5 min read
Small interfaces are easier to understand on the first visit and easier to maintain on the hundredth release.
Start with the boundary
A useful interface begins by deciding what it will not ask the user to manage.
Keep the controls that change the outcome and move rare configuration closer to the moment it matters.
Make the default path obvious
A strong default removes a decision without hiding what happened.
- Name the primary action precisely.
- Show the current state beside the control that changes it.
- Keep advanced choices available without making them mandatory.
설치
기본 테마와 Pretendard는 설치 안내에 따라 한 번 설정하세요. 이 템플릿은 기존 테마·전역 CSS·페이지 경로를 덮어쓰지 않습니다.
설치: template-blog-post
npx shadcn@latest add @neumorphism-ui/template-blog-post사용 예제
사용 예제
import { BlogPost, type BlogArticle } from "@/components/blocks/blog-post";
export function Example({ post }: { post: BlogArticle }) {
return <BlogPost post={post} locale="ko" backHref="/blog" />;
}연결 방식
post 데이터와 backHref만 전달하세요. 이 템플릿은 정적 렌더링만 담당하며 실제 slug 라우팅과 콘텐츠 로딩은 애플리케이션이 소유합니다.
설치되는 소스
@components/blocks/blog-post.tsx
@components/blocks/blog-post.tsx
import { cn } from "@/lib/utils";
export type BlogPostLocale = "ko" | "en" | "ja" | "zh";
export type BlogPostSection = {
heading: string;
paragraphs: readonly string[];
points?: readonly string[];
};
export type BlogArticle = {
slug: string;
title: string;
summary: string;
topic: string;
publishedAt: string;
publishedLabel: string;
readTime: string;
intro: string;
sections: readonly BlogPostSection[];
};
export interface BlogPostProps {
post: BlogArticle;
backHref?: string;
locale?: BlogPostLocale;
className?: string;
}
const copy = {
ko: { all: "모든 글", back: "맨 위로" },
en: { all: "All posts", back: "Back to top" },
ja: { all: "すべての記事", back: "先頭へ" },
zh: { all: "全部文章", back: "返回顶部" },
} as const;
export function BlogPost({
post,
backHref = "/blog",
locale = "en",
className,
}: BlogPostProps) {
const t = copy[locale];
return (
<article
id="blog-post-top"
data-slot="blog-post"
className={cn("mx-auto w-full max-w-3xl text-[var(--foreground)]", className)}
>
<header className="grid gap-5 border-b border-[var(--border)] pb-7">
<a
href={backHref}
className="w-fit rounded-[var(--neu-radius-control)] border border-[color:var(--neu-edge)] bg-[var(--neu-surface)] px-4 py-2 text-sm font-semibold no-underline [box-shadow:var(--neu-shadow-raised-sm)] outline-none hover:[box-shadow:var(--neu-shadow-hover)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] active:[box-shadow:var(--neu-shadow-inset)]"
>
← {t.all}
</a>
<p className="text-sm font-semibold text-[var(--muted-foreground)]">{post.topic}</p>
<h1 className="max-w-2xl text-4xl font-semibold leading-tight tracking-tight sm:text-5xl">
{post.title}
</h1>
<p className="max-w-2xl text-lg leading-relaxed text-[var(--muted-foreground)]">
{post.summary}
</p>
<div className="flex flex-wrap items-center gap-3 text-sm text-[var(--muted-foreground)]">
<time dateTime={post.publishedAt}>{post.publishedLabel}</time>
<span aria-hidden="true">·</span>
<span>{post.readTime}</span>
</div>
</header>
<p className="my-8 rounded-[var(--neu-radius-surface)] border border-[color:var(--neu-edge)] bg-[var(--neu-surface)] px-5 py-5 text-lg leading-relaxed [box-shadow:var(--neu-shadow-inset)] sm:px-7">
{post.intro}
</p>
<div className="grid gap-10">
{post.sections.map((section, index) => {
const headingId = post.slug + "-section-" + (index + 1);
return (
<section key={section.heading} aria-labelledby={headingId} className="grid gap-4">
<h2 id={headingId} className="text-2xl font-semibold tracking-tight sm:text-3xl">
{section.heading}
</h2>
<div className="grid gap-4 text-base leading-8 text-[var(--muted-foreground)] sm:text-lg">
{section.paragraphs.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
</div>
{section.points?.length ? (
<ul className="grid list-disc gap-2 pl-6 text-base leading-7 text-[var(--muted-foreground)]">
{section.points.map((point) => <li key={point}>{point}</li>)}
</ul>
) : null}
</section>
);
})}
</div>
<footer className="mt-10 flex items-center justify-between gap-4 border-t border-[var(--border)] pt-5 text-sm text-[var(--muted-foreground)]">
<a href={backHref} className="hover:underline">{t.all}</a>
<a href="#blog-post-top" className="hover:underline">{t.back}</a>
</footer>
</article>
);
}
const exampleArticle: BlogArticle = {
slug: "small-interfaces",
title: "Why small interfaces age better",
summary: "A practical case for fewer controls, clearer defaults, and less maintenance.",
topic: "Design",
publishedAt: "2026-09-12",
publishedLabel: "Sep 12, 2026",
readTime: "5 min read",
intro: "Small interfaces are easier to understand on the first visit and easier to maintain on the hundredth release.",
sections: [
{
heading: "Start with the boundary",
paragraphs: [
"A useful interface begins by deciding what it will not ask the user to manage.",
"Keep the controls that change the outcome and move rare configuration closer to the moment it matters.",
],
},
{
heading: "Make the default path obvious",
paragraphs: [
"A strong default removes a decision without hiding what happened.",
],
points: [
"Name the primary action precisely.",
"Show the current state beside the control that changes it.",
"Keep advanced choices available without making them mandatory.",
],
},
],
};
export function BlogPostExample({ locale = "en" }: { locale?: BlogPostLocale }) {
return <BlogPost post={exampleArticle} locale={locale} backHref="#all-posts" />;
}