Heading

A heading whose level and size are two props. level sets the tag, so the document outline stays honest; size sets the type role, so the page can still look the way it should.

Typography Heading.jsx Heading.d.ts Heading.md

Playground

Change a prop and watch it. The code below follows along.

Heading — Level sets the tag, size sets the type role.

Install and import

Heading is in @dovetail-ds/react. Install it with React, if you haven't:

npm install @dovetail-ds/react react react-dom
import { Heading } from "@dovetail-ds/react";

Import @dovetail-ds/react/styles.css once at your app's root, then your theme after it. Setup covers the stylesheets, fonts and dark mode.

Guidelines

A heading whose level and size are two props. level sets the tag, so the document outline stays honest; size sets the type role, so the page can still look the way it should.

Use it when

  • Any heading on a page: a hero title, a section title, a card title that needs to be a real heading.
  • A headline or a key number should be poster-sized: display-xl and display-2xl are fluid, largest on a wide screen and shrinking to fit a phone. Keep them to one per screen.
  • The visual size and the outline disagree. A hero's one h1 is often set at display-md; a product name inside a card is an h3 set at heading-sm.

Don't use it when

  • The text is a label, not a heading. An eyebrow above a title is Text variant="eyebrow", not a small heading.
  • It sits inside Prose. Prose styles plain h2 and h3 itself.

Example

<Stack gap="sm">
  <Text variant="eyebrow">New season</Text>
  <Heading level={1} size="display-md">Built for the trail</Heading>
  <Text variant="lead">Four days, three huts, one pack.</Text>
</Stack>

One h1 per page. Do not skip levels to get a smaller size; pick the right level and pass size.

Tone

tone defaults to headline, which reads --dt-text-headline. That role is ink until a theme or Configure's Headline colour sets it to a brand colour, and then every heading follows at once. A brand, photo or dark Section re-points it for its own surface, so a heading in a band still reads correctly without a prop.

brand and brand-secondary set a single heading in a brand colour, through --dt-text-brand and --dt-text-brand-secondary, while the rest stay as they are. Both are text roles, tuned for text contrast in light and dark mode. inherit takes the parent's colour, for a heading inside a coloured block you built by hand rather than with Section.

<Heading level={1} size="display-md" tone="brand">Built for the trail</Heading>

Tokens

--dt-text-{size}-family, -size, -line, -weight, -tracking, --dt-text-headline, --dt-text-brand, --dt-text-brand-secondary, and --dt-measure-* when measure is set. A display face and a headline colour chosen in Configure reach every heading through these roles.

Props

The typed contract, from Heading.d.ts.

import * as React from "react";

/**
 * A heading whose document level and visual size are set separately, so a
 * page's outline and its scale can differ on purpose: an h2 set at display
 * size for a hero, an h3 set small inside a card.
 */
export interface HeadingProps extends React.HTMLAttributes<HTMLHeadingElement> {
  /** Sets the tag, h1 to h6. @default 2 */
  level?: 1 | 2 | 3 | 4 | 5 | 6;
  /** The type role it is set in. Defaults by level: 1 heading-xl, 2 heading-lg, 3 heading-md, 4 heading-sm, 5 and 6 heading-xs. display-xl and display-2xl are fluid: they shrink with the screen, so a poster-sized headline or number still fits at 390px. */
  size?: "display-2xl" | "display-xl" | "display-lg" | "display-md" | "display-sm" | "heading-xl" | "heading-lg" | "heading-md" | "heading-sm" | "heading-xs";
  /**
   * headline reads --dt-text-headline: ink unless a theme sets headlines in a
   * brand colour, and re-pointed by a brand, photo or dark Section so it
   * follows the band. brand and brand-secondary set this one heading in a
   * brand text colour. inherit takes the colour of its parent, for a heading
   * inside a hand-built coloured block. @default "headline"
   */
  tone?: "headline" | "brand" | "brand-secondary" | "primary" | "secondary" | "inherit";
  align?: React.CSSProperties["textAlign"];
  /** Bounds the line length. @default "none" */
  measure?: "narrow" | "default" | "wide" | "none";
  /** Balances the lines, so a two-line heading does not end on one word. @default true */
  balance?: boolean;
  children?: React.ReactNode;
}

export declare function Heading(props: HeadingProps): React.JSX.Element;

Tokens it reads

Every custom property Heading.jsx resolves, read from the source: 150 semantic, 4 primitive. Component tier first, because that is the one to reach for.

  • semantic Shared roles. Override one and everything using that role moves with it.
  • primitive Raw values. A component reading one directly is a deliberate exception.
TokenTierDeclared as
--dt-text-artboard-body-linesemanticcalc(var(--dt-line-height-md) * 2.35)
--dt-text-artboard-body-sizesemanticcalc(var(--dt-font-size-md) * 2.5)
--dt-text-artboard-display-familysemanticvar(--dt-text-display-lg-family)
--dt-text-artboard-display-linesemanticcalc(var(--dt-line-height-7xl) * 2.55)
--dt-text-artboard-display-sizesemanticcalc(var(--dt-font-size-7xl) * 2.8)
--dt-text-artboard-display-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-artboard-display-weightsemanticvar(--dt-text-display-lg-weight)
--dt-text-artboard-meta-sizesemanticcalc(var(--dt-font-size-sm) * 2.2)
--dt-text-artboard-meta-trackingsemanticvar(--dt-tracking-wider)
--dt-text-artboard-title-linesemanticcalc(var(--dt-line-height-7xl) * 1.7)
--dt-text-artboard-title-sizesemanticcalc(var(--dt-font-size-7xl) * 1.75)
--dt-text-body-lg-familysemanticvar(--dt-font-family-sans)
--dt-text-body-lg-linesemanticvar(--dt-line-height-lg)
--dt-text-body-lg-sizesemanticvar(--dt-font-size-lg)
--dt-text-body-lg-trackingsemanticvar(--dt-tracking-normal)
--dt-text-body-lg-weightsemanticvar(--dt-font-weight-regular)
--dt-text-body-md-familysemanticvar(--dt-font-family-sans)
--dt-text-body-md-linesemanticvar(--dt-line-height-md)
--dt-text-body-md-sizesemanticvar(--dt-font-size-md)
--dt-text-body-md-trackingsemanticvar(--dt-tracking-normal)
--dt-text-body-md-weightsemanticvar(--dt-font-weight-regular)
--dt-text-body-sm-familysemanticvar(--dt-font-family-sans)
--dt-text-body-sm-linesemanticvar(--dt-line-height-sm)
--dt-text-body-sm-sizesemanticvar(--dt-font-size-sm)
--dt-text-body-sm-trackingsemanticvar(--dt-tracking-normal)
--dt-text-body-sm-weightsemanticvar(--dt-font-weight-regular)
--dt-text-body-xs-familysemanticvar(--dt-font-family-sans)
--dt-text-body-xs-linesemanticvar(--dt-line-height-xs)
--dt-text-body-xs-sizesemanticvar(--dt-font-size-xs)
--dt-text-body-xs-trackingsemanticvar(--dt-tracking-normal)
--dt-text-body-xs-weightsemanticvar(--dt-font-weight-regular)
--dt-text-brandsemanticvar(--dt-color-primary-700)
--dt-text-brand-secondarysemanticvar(--dt-color-secondary-700)
--dt-text-code-md-familysemanticvar(--dt-font-family-mono)
--dt-text-code-md-linesemanticvar(--dt-line-height-sm)
--dt-text-code-md-sizesemanticvar(--dt-font-size-sm)
--dt-text-code-md-trackingsemanticvar(--dt-tracking-normal)
--dt-text-code-md-weightsemanticvar(--dt-font-weight-regular)
--dt-text-code-sm-familysemanticvar(--dt-font-family-mono)
--dt-text-code-sm-linesemanticvar(--dt-line-height-xs)
--dt-text-code-sm-sizesemanticvar(--dt-font-size-xs)
--dt-text-code-sm-trackingsemanticvar(--dt-tracking-normal)
--dt-text-code-sm-weightsemanticvar(--dt-font-weight-regular)
--dt-text-dangersemanticvar(--dt-color-red-800)
--dt-text-disabledsemanticvar(--dt-color-neutral-400)
--dt-text-display-2xl-familysemanticvar(--dt-font-family-sans)
--dt-text-display-2xl-linesemanticvar(--dt-line-height-fluid)
--dt-text-display-2xl-sizesemanticvar(--dt-font-size-fluid-2xl)
--dt-text-display-2xl-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-display-2xl-weightsemanticvar(--dt-font-weight-medium)
--dt-text-display-lg-familysemanticvar(--dt-font-family-sans)
--dt-text-display-lg-linesemanticvar(--dt-line-height-7xl)
--dt-text-display-lg-sizesemanticvar(--dt-font-size-7xl)
--dt-text-display-lg-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-display-lg-weightsemanticvar(--dt-font-weight-medium)
--dt-text-display-md-familysemanticvar(--dt-font-family-sans)
--dt-text-display-md-linesemanticvar(--dt-line-height-6xl)
--dt-text-display-md-sizesemanticvar(--dt-font-size-6xl)
--dt-text-display-md-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-display-md-weightsemanticvar(--dt-font-weight-medium)
--dt-text-display-sm-familysemanticvar(--dt-font-family-sans)
--dt-text-display-sm-linesemanticvar(--dt-line-height-5xl)
--dt-text-display-sm-sizesemanticvar(--dt-font-size-5xl)
--dt-text-display-sm-trackingsemanticvar(--dt-tracking-tighter)
--dt-text-display-sm-weightsemanticvar(--dt-font-weight-medium)
--dt-text-display-xl-familysemanticvar(--dt-font-family-sans)
--dt-text-display-xl-linesemanticvar(--dt-line-height-fluid)
--dt-text-display-xl-sizesemanticvar(--dt-font-size-fluid-xl)
--dt-text-display-xl-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-display-xl-weightsemanticvar(--dt-font-weight-medium)
--dt-text-eyebrow-familysemanticvar(--dt-font-family-secondary)
--dt-text-eyebrow-linesemanticvar(--dt-line-height-2xs)
--dt-text-eyebrow-sizesemanticvar(--dt-font-size-2xs)
--dt-text-eyebrow-trackingsemanticvar(--dt-tracking-wider)
--dt-text-eyebrow-weightsemanticvar(--dt-font-weight-semibold)
--dt-text-heading-lg-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-lg-linesemanticvar(--dt-line-height-3xl)
--dt-text-heading-lg-sizesemanticvar(--dt-font-size-3xl)
--dt-text-heading-lg-trackingsemanticvar(--dt-tracking-tight)
--dt-text-heading-lg-weightsemanticvar(--dt-font-weight-medium)
--dt-text-heading-md-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-md-linesemanticvar(--dt-line-height-2xl)
--dt-text-heading-md-sizesemanticvar(--dt-font-size-2xl)
--dt-text-heading-md-trackingsemanticvar(--dt-tracking-tight)
--dt-text-heading-md-weightsemanticvar(--dt-font-weight-medium)
--dt-text-heading-sm-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-sm-linesemanticvar(--dt-line-height-xl)
--dt-text-heading-sm-sizesemanticvar(--dt-font-size-xl)
--dt-text-heading-sm-trackingsemanticvar(--dt-tracking-snug)
--dt-text-heading-sm-weightsemanticvar(--dt-font-weight-medium)
--dt-text-heading-xl-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-xl-linesemanticvar(--dt-line-height-4xl)
--dt-text-heading-xl-sizesemanticvar(--dt-font-size-4xl)
--dt-text-heading-xl-trackingsemanticvar(--dt-tracking-tighter)
--dt-text-heading-xl-weightsemanticvar(--dt-font-weight-medium)
--dt-text-heading-xs-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-xs-linesemanticvar(--dt-line-height-lg)
--dt-text-heading-xs-sizesemanticvar(--dt-font-size-lg)
--dt-text-heading-xs-trackingsemanticvar(--dt-tracking-normal)
--dt-text-heading-xs-weightsemanticvar(--dt-font-weight-medium)
--dt-text-headlinesemanticvar(--dt-text-primary)
--dt-text-infosemanticvar(--dt-color-cyan-900)
--dt-text-inversesemanticvar(--dt-color-neutral-050)
--dt-text-label-lg-familysemanticvar(--dt-font-family-secondary)
--dt-text-label-lg-linesemanticvar(--dt-line-height-md)
--dt-text-label-lg-sizesemanticvar(--dt-font-size-md)
--dt-text-label-lg-trackingsemanticvar(--dt-tracking-normal)
--dt-text-label-lg-weightsemanticvar(--dt-font-weight-medium)
--dt-text-label-md-familysemanticvar(--dt-font-family-secondary)
--dt-text-label-md-linesemanticvar(--dt-line-height-sm)
--dt-text-label-md-sizesemanticvar(--dt-font-size-sm)
--dt-text-label-md-trackingsemanticvar(--dt-tracking-normal)
--dt-text-label-md-weightsemanticvar(--dt-font-weight-medium)
--dt-text-label-sm-familysemanticvar(--dt-font-family-secondary)
--dt-text-label-sm-linesemanticvar(--dt-line-height-xs)
--dt-text-label-sm-sizesemanticvar(--dt-font-size-xs)
--dt-text-label-sm-trackingsemanticvar(--dt-tracking-normal)
--dt-text-label-sm-weightsemanticvar(--dt-font-weight-medium)
--dt-text-on-actionsemanticvar(--dt-color-white)
--dt-text-on-action-brandsemanticvar(--dt-color-white)
--dt-text-on-action-brand-secondarysemanticvar(--dt-color-white)
--dt-text-on-action-dangersemanticvar(--dt-color-white)
--dt-text-on-action-disabledsemanticvar(--dt-color-neutral-400)
--dt-text-on-action-ghostsemanticvar(--dt-color-neutral-900)
--dt-text-on-action-secondarysemanticvar(--dt-color-neutral-900)
--dt-text-on-brandsemanticvar(--dt-color-white)
--dt-text-on-brand-mutedsemanticvar(--dt-color-primary-900)
--dt-text-on-brand-secondarysemanticvar(--dt-color-white)
--dt-text-on-brand-secondary-mutedsemanticvar(--dt-color-secondary-900)
--dt-text-on-dangersemanticvar(--dt-color-white)
--dt-text-on-infosemanticvar(--dt-color-white)
--dt-text-on-scrimsemanticvar(--dt-color-neutral-050)
--dt-text-on-scrim-brandsemanticvar(--dt-color-primary-200)
--dt-text-on-scrim-secondarysemanticvar(--dt-color-neutral-200)
--dt-text-on-scrim-strongsemanticvar(--dt-color-white)
--dt-text-on-selectedsemanticvar(--dt-color-neutral-900)
--dt-text-on-selected-brandsemanticvar(--dt-color-primary-900)
--dt-text-on-successsemanticvar(--dt-color-white)
--dt-text-on-warningsemanticvar(--dt-color-neutral-950)
--dt-text-primarysemanticvar(--dt-color-neutral-900)
--dt-text-secondarysemanticvar(--dt-color-neutral-600)
--dt-text-successsemanticvar(--dt-color-green-800)
--dt-text-tertiarysemanticvar(--dt-color-neutral-500)
--dt-text-warningsemanticvar(--dt-color-amber-900)
--dt-text-wordmarksemanticvar(--dt-text-primary)
--dt-color-secondary-700primitiveoklch(0.518 0.190 305)
--dt-measure-defaultprimitive68ch
--dt-measure-narrowprimitive45ch
--dt-measure-wideprimitive85ch

Source

React only, no dependencies, every value a token.

import React from "react";

const DEFAULT_SIZE = { 1: "heading-xl", 2: "heading-lg", 3: "heading-md", 4: "heading-sm", 5: "heading-xs", 6: "heading-xs" };
const SIZES = ["display-2xl", "display-xl", "display-lg", "display-md", "display-sm", "heading-xl", "heading-lg", "heading-md", "heading-sm", "heading-xs"];
const TONES = {
  headline: "var(--dt-text-headline, var(--dt-text-primary))",
  brand: "var(--dt-text-brand, var(--dt-text-link))",
  "brand-secondary": "var(--dt-text-brand-secondary, var(--dt-color-secondary-700, var(--dt-text-link)))",
  primary: "var(--dt-text-primary)",
  secondary: "var(--dt-text-secondary)",
  inherit: "inherit",
};
const MEASURES = { narrow: "var(--dt-measure-narrow)", default: "var(--dt-measure-default)", wide: "var(--dt-measure-wide)", none: "none" };

export function Heading({ level = 2, size, tone = "headline", align, measure = "none", balance = true, children, style, ...rest }) {
  const lvl = Math.min(6, Math.max(1, Number(level) || 2));
  const Tag = "h" + lvl;
  const role = SIZES.indexOf(size) !== -1 ? size : DEFAULT_SIZE[lvl];
  return (
    <Tag
      style={{
        margin: 0,
        fontFamily: `var(--dt-text-${role}-family)`,
        fontSize: `var(--dt-text-${role}-size)`,
        lineHeight: `var(--dt-text-${role}-line)`,
        fontWeight: `var(--dt-text-${role}-weight)`,
        letterSpacing: `var(--dt-text-${role}-tracking)`,
        color: TONES[tone] || TONES.headline,
        textAlign: align,
        maxWidth: MEASURES[measure] || MEASURES.none,
        textWrap: balance ? "balance" : undefined,
        ...style,
      }}
      {...rest}
    >
      {children}
    </Tag>
  );
}