Post hero
The opening band of a post. It gathers the way back, tag pills, a big heavy title, the summary, the dates and the translation and Markdown links, with an optional illustration beside them.
- #arc
- #block
- #hero
- #header
- #post
- #article
- #blog
- #title
- #marketing
Install
One command installs the files and packages this item needs.
npx shadcn@latest add https://dsui.vercel.app/r/post-hero.jsonFiles
components/arc/blocks/post-hero/post-hero.module.csscomponents/arc/blocks/post-hero/post-hero.tsx
Registry items it pulls in
Source
View as Markdown/* The hero sits inside a StoryBand and reads its colours: --band-ink for the summary, --hero-sub for the dates. On the plain face the
dates are the page's secondary text; on a coloured face they take the band ink. lib/theme/contrast.test.ts (dsui) reads these blocks
with the band's tone blocks and checks --hero-sub at 4.5:1 for every theme in light and dark. */
.hero { --hero-sub: var(--text-secondary); }
.hero[data-tone="yellow"] { --hero-sub: var(--band-ink); }
.hero[data-tone="blue"] { --hero-sub: var(--band-ink); }
.hero {
display: grid;
gap: clamp(var(--space-10), 5vw, 72px);
align-items: center;
}
@media (min-width: 900px) {
.hero[data-illustrated="true"] { grid-template-columns: minmax(0, 7fr) minmax(0, 5fr); }
}
.text { display: flex; min-width: 0; flex-direction: column; align-items: flex-start; gap: var(--space-5); }
.text > * { margin: 0; }
.back { margin-bottom: var(--space-3); }
.tags { margin: 0; }
.summary {
max-width: 46ch;
color: var(--band-ink);
font-size: clamp(17px, 1.8vw, 22px);
font-weight: 500;
line-height: 1.6;
text-wrap: pretty;
word-break: keep-all;
}
.meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2) var(--space-3);
color: var(--hero-sub);
font-size: var(--text-sm);
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.dot { width: 4px; height: 4px; border-radius: var(--radius-pill); background: currentColor; }
.actions { display: flex; flex-wrap: wrap; gap: var(--space-2); }
/* The illustration has its own cell, so no text is ever drawn over it. On a coloured face it sits on a surface panel, because its
blue and yellow shapes are the band's own colours and would vanish into it. */
.art { width: 100%; max-width: 520px; justify-self: center; }
.hero:not([data-tone="plain"]) .art { padding: clamp(var(--space-4), 3vw, var(--space-8)); border-radius: var(--radius-surface); background: var(--surface); }
import { readFileSync } from "node:fs";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { PostHero, type PostHeroProps } from "./post-hero";
const base: PostHeroProps = {
title: "디자인된 글을 써요",
summary: "띠와 큰 제목으로 글을 보여 줘요.",
date: "2026-10-26",
dateLabel: "작성",
updatedLabel: "수정",
};
const render = (props: Partial<PostHeroProps> = {}) => renderToStaticMarkup(createElement(PostHero, { ...base, ...props }));
describe("PostHero server render", () => {
it("is a band with exactly one h1 that carries the title, then the summary", () => {
const html = render();
expect(html).toMatch(/^<section[\s>]/);
expect(html.match(/<h1[\s>]/g)).toHaveLength(1);
expect(html).toContain('aria-label="디자인된 글을 써요"');
expect(html).toContain("띠와 큰 제목으로 글을 보여 줘요.");
expect(html.indexOf("<h1")).toBeLessThan(html.indexOf("띠와 큰 제목으로"));
});
it("renders a title with | and * literally instead of as headline markers", () => {
const html = render({ title: "A|B *별* 제목" });
expect(html).toContain('aria-label="A|B *별* 제목"');
expect(html).toContain(">A|B</span>");
expect(html).toContain(">*별*</span>");
});
it("maps the tone to the band and plain by default", () => {
expect(render()).toContain('data-tone="plain"');
for (const tone of ["plain", "yellow", "blue"] as const) expect(render({ tone })).toContain(`data-tone="${tone}"`);
});
it("writes the dates as <time> with the labels the app passes", () => {
const html = render({ updated: "2026-10-27" });
expect(html).toMatch(/작성\s*<time dateTime="2026-10-26">2026-10-26<\/time>/);
expect(html).toMatch(/수정\s*<time dateTime="2026-10-27">2026-10-27<\/time>/);
expect(render()).not.toContain("수정");
});
it("links the tags as pill chips in a list", () => {
const html = render({ tags: [{ label: "#dsui", href: "/blog/tags/dsui" }, { label: "#blog", href: "/blog/tags/blog" }] });
expect(html).toMatch(/<ul[^>]*>\s*<li><a[^>]*href="\/blog\/tags\/dsui"[^>]*>.*#dsui/);
expect(html).toContain('href="/blog/tags/blog"');
expect(render()).not.toContain("<ul");
});
it("puts the back link first and the actions as link buttons with their language", () => {
const html = render({
back: { href: "/blog", label: "블로그" },
actions: [
{ href: "/en/blog/designed", label: "이 글의 영어판", plain: true, hrefLang: "en", lang: "en" },
{ href: "/blog/designed.md", label: "마크다운으로 보기", plain: true },
],
});
expect(html).toMatch(/<a[^>]*href="\/blog"[^>]*>.*블로그/);
expect(html.indexOf('href="/blog"')).toBeLessThan(html.indexOf("<h1"));
expect(html).toMatch(/<a[^>]*href="\/en\/blog\/designed"[^>]*hrefLang="en"[^>]*lang="en"|<a[^>]*hrefLang="en"[^>]*href="\/en\/blog\/designed"/);
expect(html).toContain('href="/blog/designed.md"');
});
it("draws the illustration beside the text, never under it", () => {
const html = render({ illustration: "hero-collage" });
expect(html).toContain("<svg");
expect(html).toContain('data-illustrated="true"');
expect(render()).not.toContain("<svg");
// The text column closes before the art column opens: no text sits on top of the picture.
expect(html.indexOf("</h1>")).toBeLessThan(html.indexOf("<svg"));
});
it("uses tokens only and names the meta colour the contrast test checks", () => {
const css = readFileSync(new URL("./post-hero.module.css", import.meta.url), "utf8");
expect(css).not.toMatch(/#[0-9a-f]{3,6}\b/i);
expect(css).not.toMatch(/rgba?\(/);
expect(css).toMatch(/--hero-sub/);
});
});
describe("PostHero edges on coloured tones", () => {
it("relies on the band to take the border colour off its back link, actions and chips", () => {
const band = readFileSync(new URL("../story-band/story-band.module.css", import.meta.url), "utf8");
expect(band).toMatch(/\.band:not\(\[data-tone="plain"\]\)[^{]*\.inner[^{]*\{[^}]*border-color:\s*transparent/);
// The hero itself never puts the edge back.
const css = readFileSync(new URL("./post-hero.module.css", import.meta.url), "utf8");
expect(css).not.toMatch(/border-color/);
});
});
"use client";
import { ButtonLink } from "../../button-link/button-link";
import { Chip, ChipList } from "../../chip/chip";
import { Illustration } from "../../illustration/illustration";
import type { IllustrationKey } from "../../illustration/spec";
import { ArcAnchor, ArcProvider } from "../../lib/arc-provider";
import { UiIcon } from "../../theme/ui-icon";
import { BigHeadline } from "../big-headline/big-headline";
import { StoryBand } from "../story-band/story-band";
import styles from "./post-hero.module.css";
export type PostHeroTone = "plain" | "yellow" | "blue";
export interface PostHeroLink {
href: string;
label: string;
}
export interface PostHeroAction extends PostHeroLink {
/** A plain `<a>` instead of the app's link component: for files (`.md`), other origins and pages in another language. */
plain?: boolean;
/** The language of the page the link opens, when it differs from this one. */
hrefLang?: string;
/** The language of the label, when it differs from the page. */
lang?: string;
}
export interface PostHeroProps {
/** The post title, shown as written (no headline markers) in the page's one `h1`. */
title: string;
summary: string;
/** The publication date, `YYYY-MM-DD`. */
date: string;
/** The last update, `YYYY-MM-DD`, when there was one. */
updated?: string;
/** Pill links above the title, such as the post's tags. */
tags?: PostHeroLink[];
/** The band's face: the page surface, the highlight (`yellow`) or the accent (`blue`). */
tone?: PostHeroTone;
/** An illustration beside the text (below it on narrow screens). */
illustration?: IllustrationKey;
/** The way back, such as the post list. */
back?: PostHeroLink;
/** The word before the date, such as "작성". */
dateLabel: string;
/** The word before the update date, such as "수정". */
updatedLabel: string;
/** Link buttons under the dates, such as the translation and the Markdown version. */
actions?: PostHeroAction[];
}
/** Links that must leave the app's client-side navigation (a file, another language) render as plain anchors. */
function ActionLink({ action }: { action: PostHeroAction }) {
const { href, label, plain, hrefLang, lang } = action;
const link = (
<ButtonLink href={href} variant="secondary" size="sm" hrefLang={hrefLang} lang={lang}>
{label}
</ButtonLink>
);
return plain ? <ArcProvider link={ArcAnchor}>{link}</ArcProvider> : link;
}
/**
* The opening band of a designed post: the way back, the tags as pills, the title as the page's `h1` in big heavy type, the summary,
* the dates and the post's links, with an optional illustration beside them. The band is `StoryBand`, so the title rises when it comes
* into view and everything is readable without scripts. The words come from the app; nothing here is written in one language.
*/
export function PostHero({
title,
summary,
date,
updated,
tags = [],
tone = "plain",
illustration,
back,
dateLabel,
updatedLabel,
actions = [],
}: PostHeroProps) {
return (
<StoryBand tone={tone}>
<div className={styles.hero} data-tone={tone} data-illustrated={illustration ? "true" : undefined}>
<div className={styles.text}>
{back ? (
<p className={styles.back}>
<ButtonLink href={back.href} variant="secondary" size="sm">
<UiIcon iconRole="arrow-left" size={16} strokeWidth={1.75} aria-hidden="true" />
{back.label}
</ButtonLink>
</p>
) : null}
{tags.length > 0 ? (
<ChipList className={styles.tags}>
{tags.map((tag, i) => (
<li key={`${i}-${tag.href}`}>
<Chip size="sm" href={tag.href}>{tag.label}</Chip>
</li>
))}
</ChipList>
) : null}
<BigHeadline lines={title} literal level={1} size="lg" />
<p className={styles.summary}>{summary}</p>
<p className={styles.meta}>
<span>
{dateLabel} <time dateTime={date}>{date}</time>
</span>
{updated ? (
<>
<span className={styles.dot} aria-hidden="true" />
<span>
{updatedLabel} <time dateTime={updated}>{updated}</time>
</span>
</>
) : null}
</p>
{actions.length > 0 ? (
<div className={styles.actions}>
{actions.map((action, i) => (
<ActionLink key={`${i}-${action.href}`} action={action} />
))}
</div>
) : null}
</div>
{illustration ? (
<div className={styles.art}>
<Illustration name={illustration} animated />
</div>
) : null}
</div>
</StoryBand>
);
}
export default PostHero;
Used items
- Story bandBlock
A full-width band on the page surface, blue, yellow or ink. When it comes into view, the big headline and the text inside rise in turn.
- Big headlineBlock
A big, heavy headline whose words rise one by one as it comes into view. Only the words marked with a star are in full ink.
- ChipComponent
A pill for a tag or filter: a link when it has an href, a plain label when it does not.
- Button linkComponent
An anchor that looks like the Button, for navigation that has to stay a real link.
- IllustrationComponent
Flat shape illustrations for heroes, section headers and card covers. A theme without its own art draws the base art in its colours.