본문으로 건너뛰기
블록

글 머리

글의 첫 띠예요. 돌아가기 링크, 태그 알약, 아주 굵은 제목, 요약, 날짜, 번역과 마크다운 링크를 한 띠에 모으고, 옆에 일러스트를 둘 수 있어요.

  • #arc
  • #block
  • #hero
  • #header
  • #post
  • #article
  • #blog
  • #title
  • #marketing

설치

이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.

터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/post-hero.json

post-hero.module.csscss
/* 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); }
post-hero.test.tsts
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/);
  });
});
post-hero.tsxtsx
"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;

사용된 항목