Subheading
Section heading component: a responsive h2 title for features, pricing, FAQ, testimonials and CTA sections. One size that scales with the section, no size prop. Pair with Text for the section description.
pnpm dlx shadcn add @01orbital/subheadingimport { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cn } from "cn"
// No width of its own: the SectionHeader column (or whatever holds it) is
// the measure. text-pretty keeps a wrapped title from ending on one word.
// font-display follows font-sans unless the block switches it, for example
// with [--font-display:var(--font-serif)].
/**
* The section heading in the display font, rendered as an `<h2>`: 30px with
* a 36px line, 40px with a 40px line from the `sm` breakpoint. One size, no
* props beyond the element's own; pass `render` to use another heading
* level. A page's headline is `Heading`.
*/
function Subheading({
className,
render,
...props
}: useRender.ComponentProps<"h2">) {
return useRender({
defaultTagName: "h2",
render,
state: { slot: "subheading" },
props: mergeProps<"h2">(
{
className: cn(
"font-display text-3xl/9 tracking-[-0.03em] text-pretty sm:text-[2.5rem]/10",
className
),
},
props
),
})
}
export { Subheading }
import {
Section,
SectionContent,
} from "@/components/section/section"
import { Subheading } from "@/components/subheading/subheading"
const COPY = {
text: "A strong start. Room to make it yours.",
}
export default function SubheadingDemo() {
return (
<Section className="py-0">
<SectionContent className="px-0 lg:px-0">
<Subheading>{COPY.text}</Subheading>
</SectionContent>
</Section>
)
}
import type { ComponentProps } from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
// : every part below measures the section, not the viewport.
// The section paints the page ground and ink; `dark` flips both, art in
// SectionBackground covers the ground, a scene ink stamps it.
// The section is the band and the stack, as Card is the box and the stack:
// inside it one 80rem column carries the gutters and the gap between header,
// content and actions. size is the band: md 96 for a product section, lg
// 128 for a marquee, sm 64 for docs.
// className styles the outer section; children sit inside a centered column.
const sectionVariants = cva(
"relative isolate w-full bg-background wrap-anywhere text-foreground",
{
variants: {
/**
* The band's top and bottom padding, and the gap between the parts
* inside it. `sm` is 64px padding with 40px gaps. `md` (the default)
* is 96px padding with 40px gaps, 64px once the section is 42rem
* wide. `lg` is 128px padding with 64px gaps.
*/
size: { sm: "py-16", md: "py-24", lg: "py-32" },
},
defaultVariants: { size: "md" },
}
)
const columnVariants = cva(
"mx-auto flex w-full max-w-7xl min-w-0 flex-col px-6 lg:px-10",
{
variants: {
size: {
sm: "gap-10",
md: "gap-10 sm:gap-16",
lg: "gap-16",
},
},
defaultVariants: { size: "md" },
}
)
/**
* A full-width page band, as a `section`. It paints the page background and
* text color, and centers its children in one column up to 80rem wide, with
* side gutters and a gap between the header, content and actions.
*
* `size` sets the top and bottom padding (`sm` 64px, `md` 96px by default,
* `lg` 128px) and, with it, the gap between parts. `className` lands on the
* outer `section`; children go inside the column. `SectionColumns` and
* blocks step at screen breakpoints for their layouts.
* Add the `dark` class to paint it with the dark theme.
*/
function Section({
className,
size = "md",
children,
...props
}: ComponentProps<"section"> & VariantProps<typeof sectionVariants>) {
return (
<section
data-slot="section"
data-size={size}
className={cn(sectionVariants({ size }), className)}
{...props}
>
<div data-slot="section-column" className={columnVariants({ size })}>
{children}
</div>
</section>
)
}
/**
* A layer for art or a fill behind a section's content. Place it as a child
* of `Section`: it covers the whole section, sits behind everything else in
* it, and is hidden from assistive tech and the pointer.
*/
function SectionBackground({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="section-background"
aria-hidden="true"
className={cn("pointer-events-none absolute inset-0 -z-10", className)}
{...props}
/>
)
}
// The body, as CardContent is to Card: a stack at 32 (sm 24) for a control
// and what it drives; lg (64) sets a header well apart from its body. width
// caps it: sm stays a reading measure at every size, md is prose, lg a form
// or cart, xl a list. full fills the frame at every size, so it always lines
// up with the header above it; md, lg and xl are centered columns that open up
// as the section widens.
const contentVariants = cva("mx-auto flex w-full min-w-0 flex-col", {
variants: {
/**
* The gap between children: `sm` 24px, `md` (the default) 32px, `lg`
* 64px.
*/
size: {
sm: "gap-6",
md: "gap-8",
lg: "gap-16",
},
/**
* The column's maximum width; the column is centered. `full` (the
* default) fills the section's column, so it lines up with the header.
* `sm` stays 42rem at every width. `md`, `lg` and `xl` start at 42rem
* and widen to 48rem once the section is 48rem wide; once it is 64rem
* wide, `lg` widens to 56rem and `xl` to 64rem. `md` suits prose, `lg`
* a form and `xl` a list.
*/
width: {
sm: "max-w-2xl",
md: "max-w-2xl md:max-w-3xl",
lg: "max-w-2xl md:max-w-3xl lg:max-w-4xl",
xl: "max-w-2xl md:max-w-3xl lg:max-w-5xl",
full: "max-w-7xl",
},
},
defaultVariants: { size: "md", width: "full" },
})
/**
* The body of a section, under `SectionHeader`: a centered column, a `div` by
* default, that stacks its children.
*
* `size` sets the gap between children: `sm` 24px, `md` (the default) 32px,
* `lg` 64px. `width` caps the column. `full` (the default) fills the section
* column, so it lines up with the header. `sm` stays 42rem at every width.
* `md`, `lg` and `xl` start at 42rem and widen as the section grows, to
* 48rem, 56rem and 64rem: `md` suits prose, `lg` a form, `xl` a list.
*/
function SectionContent({
className,
size = "md",
width = "full",
render,
...props
}: useRender.ComponentProps<"div"> & VariantProps<typeof contentVariants>) {
return useRender({
defaultTagName: "div",
render,
state: { slot: "section-content", width },
props: mergeProps<"div">(
{
className: cn(contentVariants({ size, width }), className),
},
props
),
})
}
// equal splits the frame in half; intro gives a header two fifths and the
// list beside it three.
const columnsVariants = cva(
"grid w-full max-w-2xl grid-cols-1 items-start gap-x-(--gutter) gap-y-(--row-gap) lg:max-w-none",
{
variants: {
/**
* How the two columns share the width once the section is 64rem wide.
* `equal` (the default) splits it in half. `intro` gives the first
* column two fifths and the second three fifths, for a header beside
* a list. Below 64rem the columns stack, up to 42rem wide.
*/
variant: {
equal: "lg:grid-cols-2",
intro: "lg:grid-cols-[2fr_3fr]",
},
},
defaultVariants: { variant: "equal" },
}
)
/**
* Two columns side by side once the section is 64rem wide, stacked and
* capped at 42rem below that. `equal` (the default) splits the width in
* half. `intro` gives the first column two fifths and the second three, for
* a header beside a list.
*/
function SectionColumns({
className,
variant = "equal",
...props
}: ComponentProps<"div"> & VariantProps<typeof columnsVariants>) {
return (
<div
data-slot="section-columns"
data-variant={variant}
className={cn(columnsVariants({ variant }), className)}
{...props}
/>
)
}
// The header owns the width, for the title too: a Subheading inside has no
// measure of its own. md is a section introduction, lg a centered CTA whose
// description wants room. split sets SectionActions beside the title.
const headerVariants = cva("group/section-header grid w-full min-w-0 gap-6", {
variants: {
/**
* The header's maximum width: `md` (the default) 42rem, `lg` 56rem.
* Not available with `variant="split"`.
*/
size: {
md: "max-w-2xl",
lg: "max-w-4xl",
},
/**
* `start` (the default) aligns the header's parts and text to the
* start. `center` centers the header in its parent and centers its
* parts and text; give a `SectionActions` inside it `align="center"`.
* Not available with `variant="split"`.
*/
align: {
start: "justify-items-start",
center: "mx-auto justify-items-center text-center",
},
variant: {
stack: "",
split:
"max-w-none sm:grid-cols-[1fr_auto] sm:items-end sm:*:data-[slot=section-actions]:col-start-2 sm:*:data-[slot=section-actions]:row-start-1",
},
},
defaultVariants: { align: "start", size: "md", variant: "stack" },
})
type SectionHeaderProps = ComponentProps<"div"> &
(
| (Omit<VariantProps<typeof headerVariants>, "variant"> & {
/**
* `stack` (the default) stacks the parts with 24px gaps. `split`
* fills the full width and, once the section is 42rem wide, sets
* `SectionActions` to the right of the title with their bottoms
* aligned; it expects that `SectionActions`. `split` takes no `size` or `align`.
*/
variant?: "stack"
})
| {
/**
* `stack` (the default) stacks the parts with 24px gaps. `split`
* fills the full width and, once the section is 42rem wide, sets
* `SectionActions` to the right of the title with their bottoms
* aligned; it expects that `SectionActions`. `split` takes no `size` or `align`.
*/
variant: "split"
size?: never
align?: never
}
)
/**
* The top of a section. Holds a `SectionTitleGroup`, an optional `Text`
* and optional `SectionActions`, stacked with 24px gaps.
*
* `size` caps its width: `md` (the default) 42rem, `lg` 56rem.
* `align="center"` centers it and its text. The `split` variant fills
* the full width and, once the section is 42rem wide, sets `SectionActions`
* to the right of the title with their bottoms aligned. It takes no `size`
* or `align`.
*/
function SectionHeader({
className,
align = "start",
size = "md",
variant = "stack",
...props
}: SectionHeaderProps) {
return (
<div
data-slot="section-header"
data-align={align}
data-size={size}
data-variant={variant}
className={cn(headerVariants({ align, size, variant }), className)}
{...props}
/>
)
}
// A CTA's actions are the Section's sibling of its header, on the section
// stack, or sit inside the header; align centers the row either way.
const sectionActionsVariants = cva(
"flex max-w-full flex-wrap items-center gap-4",
{
variants: {
/**
* `start` (the default) packs the row at the start. `center` centers
* it.
*/
align: {
start: "",
center: "justify-center",
},
},
defaultVariants: { align: "start" },
}
)
/**
* A row of buttons or links, with 16px gaps, that wraps when space runs out.
* Place it in `SectionHeader`, or directly in `Section` after the header for
* a call to action. `align="center"` centers the row.
*/
function SectionActions({
className,
align = "start",
...props
}: ComponentProps<"div"> & VariantProps<typeof sectionActionsVariants>) {
return (
<div
data-slot="section-actions"
data-align={align}
className={cn(sectionActionsVariants({ align }), className)}
{...props}
/>
)
}
// Stacks whatever it wraps: an eyebrow and title at sm, a title and its
// subheading at lg. row sets two columns side by side from 1024px, their
// feet level; gap is then the space when they stack.
const titleGroupVariants = cva("grid w-full min-w-0", {
variants: {
/**
* `column` (the default) stacks the children. `row` sets them in two
* columns once the section is 64rem wide, with their bottoms aligned
* and 64px (`--gutter`) between the columns.
*/
direction: {
column: "",
row: "lg:grid-cols-2 lg:items-end lg:gap-x-(--gutter)",
},
/**
* The space between the children: `sm` (the default) 8px, `md` 12px,
* `lg` 24px. In a `row` group, once the section is 64rem wide, it sets
* only the space between rows.
*/
gap: {
sm: "gap-2",
md: "gap-3",
lg: "gap-6",
},
},
defaultVariants: { direction: "column", gap: "sm" },
})
/**
* Stacks a section title with what belongs to it, such as an eyebrow above
* it or a subheading below. `gap` sets the space between them: `sm` (the
* default) 8px, `md` 12px, `lg` 24px.
*
* `direction="row"` sets two children side by side once the section is 64rem
* wide, with their bottoms aligned. `gap` then applies only while they stack.
*/
function SectionTitleGroup({
className,
direction = "column",
gap = "sm",
...props
}: ComponentProps<"div"> & VariantProps<typeof titleGroupVariants>) {
return (
<div
data-slot="section-title-group"
data-direction={direction}
data-gap={gap}
className={cn(titleGroupVariants({ direction, gap }), className)}
{...props}
/>
)
}
export {
Section,
SectionBackground,
SectionContent,
SectionColumns,
SectionHeader,
SectionTitleGroup,
SectionActions,
}
The example also uses section. Install it too:
pnpm dlx shadcn add @01orbital/sectionnpx shadcn add @01orbital/sectionyarn shadcn add @01orbital/sectionbunx --bun shadcn add @01orbital/sectionUsage
import {
Section,
SectionContent,
} from "@/components/section/section"
import { Subheading } from "@/components/subheading/subheading"
const COPY = {
text: "A strong start. Room to make it yours.",
}
export default function SubheadingDemo() {
return (
<Section className="py-0">
<SectionContent className="px-0 lg:px-0">
<Subheading>{COPY.text}</Subheading>
</SectionContent>
</Section>
)
}Examples
New
subheading-observatory-public-night
API reference
The section heading in the display font, rendered as an <h2>: 30px with a 36px line, 40px with a 40px line from the sm breakpoint. One size, no props beyond the element's own; pass render to use another heading level. A page's headline is Heading.
It accepts the props of the Base UI part it wraps, and the HTML attributes of the element it renders.
Dependencies
npm: @base-ui/react, cn. The CLI installs them for you.
No other registry pieces.