Editorial

Section

Page section frame: a container-query band at three paddings around a centred 80rem column, with background, stack or split header, title group, description, width-capped content, columns and actions.

Free, MIT licenseOpen in Browse
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"

// @container/section: 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(
  "@container/section 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 @5xl/section:px-10",
  {
    variants: {
      size: {
        sm: "gap-10",
        md: "gap-10 @2xl/section: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. Parts inside it, such as
 * `SectionColumns` and `QuoteGroup`, step their layout by the section's
 * width, not the viewport's. 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 centred 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 @3xl/section:max-w-3xl",
      lg: "max-w-2xl @3xl/section:max-w-3xl @5xl/section:max-w-4xl",
      xl: "max-w-2xl @3xl/section:max-w-3xl @5xl/section: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,
  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) @5xl/section: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: "@5xl/section:grid-cols-2",
        intro: "@5xl/section: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; split sets SectionActions beside the title.
// lg is the standalone CTA: the title runs to 4xl, its paragraph stops at 3xl.
const headerVariants = cva(
  "group/section-header @container/section-header grid w-full min-w-0 gap-6",
  {
    variants: {
      /**
       * The header's maximum width: `sm` 32rem, `md` (the default) 42rem,
       * `lg` and `xl` 56rem. With `lg`, a `SectionDescription` inside stops
       * at 48rem. Not available with `variant="split"`.
       */
      size: {
        sm: "max-w-lg",
        md: "max-w-2xl",
        lg: "max-w-4xl",
        xl: "max-w-4xl",
      },
      /**
       * `start` (the default) aligns the header's parts and text to the
       * start. `center` centers the header in its parent, centers its parts
       * and text, and centers a `SectionActions` row inside it. Not
       * available with `variant="split"`.
       */
      align: {
        start: "justify-items-start",
        center: "mx-auto justify-items-center text-center",
      },
      variant: {
        stack: "",
        split:
          "max-w-none @2xl/section:grid-cols-[1fr_auto] @2xl/section:items-end @2xl/section:*:data-[slot=section-actions]:col-start-2 @2xl/section:*: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. `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. `split` takes no `size` or `align`.
         */
        variant: "split"
        size?: never
        align?: never
      }
  )

/**
 * The top of a section. Holds a `SectionTitleGroup`, an optional
 * `SectionDescription` and optional `SectionActions`, stacked with 24px gaps.
 *
 * `size` caps its width: `sm` 32rem, `md` (the default) 42rem, `lg` and `xl`
 * 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,
  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; align centres them. Inside a centred SectionHeader they centre too.
const sectionActionsVariants = cva(
  "flex max-w-full flex-wrap items-center gap-4 group-data-[align=center]/section-header:justify-center",
  {
    variants: {
      /**
       * `start` (the default) packs the row at the start. `center` centers
       * it. Inside a `SectionHeader` with `align="center"` the row centers
       * on its own.
       */
      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; inside a centered
 * `SectionHeader` it centers on its own.
 */
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 @2xl/section:group-data-[variant=split]/section-header:col-start-1",
  {
    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: "@5xl/section:grid-cols-2 @5xl/section:items-end @5xl/section: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}
    />
  )
}

// The lead, as CardDescription is to CardTitle: one paragraph by default, in
// the title's ink; muted steps it down. Several paragraphs take
// render={<div />} and bring their own <p>.
const descriptionVariants = cva(
  "w-full min-w-0 text-pretty group-data-[size=lg]/section-header:max-w-3xl @2xl/section:group-data-[variant=split]/section-header:col-start-1",
  {
    variants: {
      /**
       * The text color. `default` (the default) keeps the inherited color,
       * which is `foreground` inside a `Section`. `muted` uses
       * `muted-foreground`.
       */
      variant: {
        default: "",
        muted: "text-muted-foreground",
      },
      /**
       * The text size: `sm` (the default) 16px with 28px lines, `md` and
       * `lg` both 18px with 32px lines.
       */
      size: {
        sm: "text-base/7",
        md: "text-lg/8",
        lg: "text-lg/8",
      },
    },
    defaultVariants: { variant: "default", size: "sm" },
  }
)

/**
 * The lead paragraph under a section title, a `p` by default, in the
 * section's text color. For several paragraphs, pass `render={<div />}` and
 * put your own `p` elements inside.
 *
 * `variant="muted"` uses the muted text color. `size` sets the text: `sm`
 * (the default) 16px, `md` and `lg` 18px. In a `SectionHeader` with
 * `size="lg"` it stops at 48rem wide.
 */
function SectionDescription({
  className,
  variant = "default",
  size = "sm",
  render,
  ...props
}: useRender.ComponentProps<"p"> & VariantProps<typeof descriptionVariants>) {
  return useRender({
    defaultTagName: "p",
    render,
    state: { slot: "section-description", variant, size },
    props: mergeProps<"p">(
      { className: cn(descriptionVariants({ variant, size }), className) },
      props
    ),
  })
}

export {
  Section,
  SectionBackground,
  SectionContent,
  SectionColumns,
  SectionHeader,
  SectionTitleGroup,
  SectionDescription,
  SectionActions,
}

Installation

pnpm dlx shadcn add @01orbital/section
npx shadcn add @01orbital/section
yarn shadcn add @01orbital/section
bunx --bun shadcn add @01orbital/section

Free under the MIT license. Add the @01orbital registry to components.json once (install guide), then run the command.

It lands at components/editorial/section.tsx, as source you own and edit.

The example also uses heading. Install it too:

pnpm dlx shadcn add @01orbital/heading
npx shadcn add @01orbital/heading
yarn shadcn add @01orbital/heading
bunx --bun shadcn add @01orbital/heading

Usage

import { Heading } from "@/components/editorial/heading"
import {
  Section,
  SectionContent,
  SectionDescription,
  SectionHeader,
  SectionTitleGroup,
} from "@/components/editorial/section"

const COPY = {
  text: "Room for every section.",
  description:
    "Shared typography, responsive spacing, and room for your content.",
}

export default function SectionDemo() {
  return (
    <Section>
      <SectionHeader>
        <SectionTitleGroup>
          <Heading>{COPY.text}</Heading>
        </SectionTitleGroup>
        <SectionDescription>{COPY.description}</SectionDescription>
      </SectionHeader>
      <SectionContent>
        <div className="h-32 rounded-xl bg-muted" aria-hidden="true" />
      </SectionContent>
    </Section>
  )
}

Anatomy

Section is built from 8 parts. In the example they nest like this:

Section
├─ SectionHeader
│  ├─ SectionTitleGroup
│  └─ SectionDescription
└─ SectionContent
  • Section: 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.
  • SectionHeader: The top of a section. Holds a SectionTitleGroup, an optional SectionDescription and optional SectionActions, stacked with 24px gaps.
  • SectionTitleGroup: 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.
  • SectionDescription: The lead paragraph under a section title, a p by default, in the section's text color. For several paragraphs, pass render={<div />} and put your own p elements inside.
  • SectionContent: The body of a section, under SectionHeader: a centered column, a div by default, that stacks its children.
  • SectionBackground: 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.
  • SectionColumns: 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.
  • SectionActions: 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; inside a centered SectionHeader it centers on its own.

API reference

Section

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. Parts inside it, such as SectionColumns and QuoteGroup, step their layout by the section's width, not the viewport's. Add the dark class to paint it with the dark theme.

Prop

Type

It also accepts the HTML attributes of the element it renders.

SectionHeader

The top of a section. Holds a SectionTitleGroup, an optional SectionDescription and optional SectionActions, stacked with 24px gaps.

size caps its width: sm 32rem, md (the default) 42rem, lg and xl 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.

Prop

Type

It also accepts the HTML attributes of the element it renders.

SectionTitleGroup

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.

Prop

Type

It also accepts the HTML attributes of the element it renders.

SectionDescription

The lead paragraph under a section title, a p by default, in the section's text color. For several paragraphs, pass render={<div />} and put your own p elements inside.

variant="muted" uses the muted text color. size sets the text: sm (the default) 16px, md and lg 18px. In a SectionHeader with size="lg" it stops at 48rem wide.

Prop

Type

It also accepts the props of the Base UI part it wraps, and the HTML attributes of the element it renders.

SectionContent

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.

Prop

Type

It also accepts the props of the Base UI part it wraps, and the HTML attributes of the element it renders.

SectionBackground

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.

It accepts the HTML attributes of the element it renders.

SectionColumns

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.

Prop

Type

It also accepts the HTML attributes of the element it renders.

SectionActions

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; inside a centered SectionHeader it centers on its own.

Prop

Type

It also accepts the HTML attributes of the element it renders.

Dependencies

npm: @base-ui/react, class-variance-authority, cn. The CLI installs them for you.

No other registry pieces.