Skip to contentVibraUI
Navigation & layout

Section header

The heading above one section of a page, with a description, actions, and an optional rule.

Server-compatible: no hooks, no client boundary. The divider is the header's own bottom border rather than a separate Separator, so the rule always tracks the header's width. Choose the heading level with as, so a page keeps its heading order intact. titleId puts an id on the heading, so the table or list the section holds can take its name from it (aria-labelledby) rather than repeat it.

Install

npx shadcn@latest add @vibra/section-header

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
titleReact.ReactNode—What the section holds.
titleIdstring—The heading's id, so a table or a list under it can be named by it with aria-labelledby.
descriptionReact.ReactNode—One line on how to read the section, capped at a readable width.
actionsReact.ReactNode—Controls for this section only, aligned with the title.
size"sm" | "default""default"sm drops the title and description a step and tightens the gap.
as"h2" | "h3""h2"The heading level to render.
dividerbooleanfalseDraws a hairline under the header.

Dependencies

Source

components/ui/section-header.tsx
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "@/lib/utils"

const sectionHeaderVariants = cva("flex flex-wrap items-end justify-between", {
  variants: {
    size: { default: "gap-4", sm: "gap-3" },
    // A rule under the header rather than a Separator beside it, so the line
    // always tracks the header's own width and spacing — and it is `--rule`,
    // the section-break weight, not the row hairline.
    divider: { true: "border-b border-rule pb-3", false: "" },
  },
  defaultVariants: { size: "default", divider: false },
})

// Two registers, one step apart: the section title is the serif at 20/500 —
// the first size the display face is used at — and `sm` drops all the way to
// the eyebrow, 11/600 in caps, for the label above a group of controls.
const TITLE_SIZE = {
  default: "type-display text-xl text-foreground",
  sm: "type-eyebrow",
} as const
const DESCRIPTION_SIZE = { default: "text-sm", sm: "text-xs" } as const

export type SectionHeaderProps = React.ComponentProps<"div"> & {
  title: React.ReactNode
  /** The heading's id, so a table or a list under it can be named by it (`aria-labelledby`). */
  titleId?: string
  /**
   * A short caps line above the title — the group this section belongs to.
   * Ignored at `sm`, where the title is itself the eyebrow.
   */
  eyebrow?: React.ReactNode
  description?: React.ReactNode
  actions?: React.ReactNode
  size?: NonNullable<VariantProps<typeof sectionHeaderVariants>["size"]>
  /** Pick the level that keeps the page's heading order intact. */
  as?: "h2" | "h3"
  divider?: boolean
}

/** The heading above one section of a page, with room for its own actions. */
function SectionHeader({
  className,
  title,
  titleId,
  eyebrow,
  description,
  actions,
  size = "default",
  as: Heading = "h2",
  divider = false,
  ...props
}: SectionHeaderProps) {
  return (
    <div
      data-slot="section-header"
      data-size={size}
      data-divided={divider || undefined}
      className={cn(sectionHeaderVariants({ size, divider }), className)}
      {...props}
    >
      <div className="flex min-w-0 flex-col gap-1">
        {eyebrow && size !== "sm" ? (
          <p data-slot="section-header-eyebrow" className="type-eyebrow">
            {eyebrow}
          </p>
        ) : null}
        <Heading
          id={titleId}
          data-slot="section-header-title"
          className={TITLE_SIZE[size]}
        >
          {title}
        </Heading>
        {description ? (
          <p
            data-slot="section-header-description"
            className={cn("max-w-prose text-muted-foreground", DESCRIPTION_SIZE[size])}
          >
            {description}
          </p>
        ) : null}
      </div>
      {actions ? (
        <div
          data-slot="section-header-actions"
          className="flex shrink-0 items-center gap-2"
        >
          {actions}
        </div>
      ) : null}
    </div>
  )
}

export { SectionHeader, sectionHeaderVariants }