uiatom
Section header
Eyebrow, heading and supporting copy, with the heading id every section needs for aria-labelledby.
Preview
Installation
pnpm dlx shadcn@latest add https://facadeui.dev/r/section-header.jsonPulls in eyebrow, heading, types, utils. The CLI installs them for you.
Usage
The exact source of the preview above.
import { Button } from "@registry/ui/button"
import { SectionHeader } from "@registry/ui/section-header"
export function Demo() {
return (
<div className="flex flex-col gap-14">
<SectionHeader
eyebrow="Features"
title="Everything a marketing page needs"
description="Composable sections that read like code you wrote yourself, because after install they are."
actions={<Button variant="outline">Browse sections</Button>}
/>
<SectionHeader
align="center"
size="lg"
title="Centred, one size larger"
description="Alignment and scale are independent of the heading level."
/>
</div>
)
}Source
What the CLI copies into your project, byte for byte.
/**
* SectionHeader — eyebrow, heading, and supporting copy, used by every section.
*
* Owns two things sections keep getting wrong: the heading *level* and the id
* that the section's `aria-labelledby` points at. Both are explicit props, and
* the id falls back to a slug of the title so the wiring works without the
* caller having to invent one.
*
* a11y: `headingLevel` must reflect the surrounding outline, not the visual
* size — `size` controls appearance and is completely independent. The eyebrow
* is a paragraph, never a heading.
*
* Dependencies: react, @/lib/types, @/lib/utils, ./eyebrow, ./heading.
*/
import type { ReactNode } from "react"
import type { HeadingLevel } from "@/lib/types"
import { cn, slugId } from "@/lib/utils"
import { Eyebrow } from "@/components/ui/eyebrow"
import { Heading } from "@/components/ui/heading"
export interface SectionHeaderProps {
title: ReactNode
/** Small label above the heading. */
eyebrow?: ReactNode
/** Supporting copy below the heading. */
description?: ReactNode
/** Outline level. Defaults to `2`. Independent of `size`. */
headingLevel?: HeadingLevel
/** Visual scale. Defaults to `md`. Independent of `headingLevel`. */
size?: "sm" | "md" | "lg" | "xl"
align?: "start" | "center"
/**
* The heading's DOM id, for the parent's `aria-labelledby`. Derived from the
* title when omitted; pass one explicitly if two headings would collide.
*/
titleId?: string
/** Buttons or links rendered beside (or under) the heading block. */
actions?: ReactNode
className?: string
children?: ReactNode
}
const headingSizes: Record<NonNullable<SectionHeaderProps["size"]>, string> = {
sm: "text-2xl font-semibold tracking-tight sm:text-3xl",
md: "text-display-sm font-semibold text-balance",
lg: "text-display-md font-semibold text-balance",
xl: "text-display-lg font-semibold text-balance",
}
const descriptionSizes: Record<NonNullable<SectionHeaderProps["size"]>, string> = {
sm: "text-base",
md: "text-base sm:text-lg",
lg: "text-lg sm:text-xl",
xl: "text-lg sm:text-xl",
}
/** Derives the heading id the way `SectionHeader` does, for `aria-labelledby`. */
export function sectionTitleId(title: string, explicit?: string): string {
return explicit ?? slugId(title)
}
export function SectionHeader({
title,
eyebrow,
description,
headingLevel = 2,
size = "md",
align = "start",
titleId,
actions,
className,
children,
}: SectionHeaderProps) {
const resolvedId = titleId ?? (typeof title === "string" ? slugId(title) : undefined)
return (
<div
className={cn(
"flex flex-col gap-4",
align === "center" && "items-center text-center",
actions && "md:flex-row md:items-end md:justify-between md:gap-10",
className,
)}
>
<div
className={cn(
"flex max-w-2xl flex-col gap-4",
align === "center" && "items-center",
)}
>
{eyebrow ? <Eyebrow>{eyebrow}</Eyebrow> : null}
<Heading level={headingLevel} id={resolvedId} className={cn(headingSizes[size])}>
{title}
</Heading>
{description ? (
<p className={cn("text-muted-foreground text-pretty", descriptionSizes[size])}>
{description}
</p>
) : null}
{children}
</div>
{actions ? <div className="flex shrink-0 flex-wrap gap-3">{actions}</div> : null}
</div>
)
}Props
SectionHeaderProps
| Prop | Type | Default |
|---|---|---|
title*Required | ReactNode | — |
eyebrowSmall label above the heading. | ReactNode | — |
descriptionSupporting copy below the heading. | ReactNode | — |
headingLevelOutline level. Defaults to `2`. Independent of `size`. | HeadingLevel | 2 |
sizeVisual scale. Defaults to `md`. Independent of `headingLevel`. | "sm" | "md" | "lg" | "xl" | "md" |
align | "start" | "center" | "start" |
titleIdThe heading's DOM id, for the parent's `aria-labelledby`. Derived from the
title when omitted; pass one explicitly if two headings would collide. | string | — |
actionsButtons or links rendered beside (or under) the heading block. | ReactNode | — |
className | string | — |
children | ReactNode | — |