MenuSection

A titled group of menu items, such as "Starters" or "Noodles", laid out as a list or as a grid of cards.

Commerce MenuSection.jsx MenuSection.d.ts MenuSection.md

Playground

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

Install and import

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

npm install @dovetail-ds/react react react-dom
import { MenuSection } 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 titled group of menu items, such as "Starters" or "Noodles", laid out as a list or as a grid of cards.

Use it when

  • Showing a menu category on a store page, one section per category, in the kitchen's order.
  • A category nav or a list of chips should be able to jump to a category: give each section an id.

Don't use it when

  • The items are not dishes or products with a price. Use List or Grid.
  • It is a page section of a landing page. Use Section and a block.

Example

<MenuSection id="noodles" title="Noodles" description="All noodles can be made with tofu instead of egg.">
  <MenuItem name="Pad thai" price={12.5} onAdd={() => openOptions("pad-thai")} />
  <MenuItem name="Pad see ew" price={12} onAdd={() => openOptions("see-ew")} />
</MenuSection>

<MenuSection title="Popular" layout="grid">…</MenuSection>

Variants

PropWhat it is for
layout="list"Default. Dishes stacked with dividers between them: the phone layout, and any narrow column.
layout="grid"Dishes as cards, two up when each column can be at least --dt-menu-grid-min wide, one up on a phone. For a wide screen, where a list would put the thumbnail far from the name.
headingLevelThe heading level, 2 by default. Each MenuItem name is set one level below.

Composition

Holds MenuItems as its children. It passes layout and a headingLevel one below its own to each MenuItem that does not set them. Other children are allowed and are wrapped in list items the same way. Sections follow the StoreHeader and FulfilmentToggle on a store page, inside an AppShell on a phone.

Tokens

Tier 3, in tokens/component/commerce.css:

  • --dt-menu-section-gap (--dt-space-stack-sm): between the heading and the dishes.
  • --dt-menu-item-divider (--dt-border-subtle, repeated under .dark): the rule between dishes in a list.
  • --dt-menu-grid-min (0.4 × --dt-size-container-narrow): the narrowest a grid column may be.

The title is --dt-text-heading-sm-*; the description --dt-text-body-sm-* in --dt-text-secondary.

Accessibility

  • The title is a heading (h2 by default), so people can move between categories by heading.
  • The dishes are a list (role="list", kept explicit because the list style is removed), so a screen reader says how many dishes the section holds.
  • The id sits on the <section>, so an in-page link moves focus and scroll to it.

Content

  • Titles are the category's name, sentence case, one to three words: "Starters", "Noodles and rice".
  • The description is one line and says something that applies to every dish: a side that comes with them, a swap on offer.

Props

The typed contract, from MenuSection.d.ts.

import * as React from "react";

/** A titled group of menu items: "Starters", "Noodles", "Drinks". */
export interface MenuSectionProps extends Omit<React.HTMLAttributes<HTMLElement>, "title"> {
  /** The section's heading. */
  title: string;
  /** One line under the heading: "Served with jasmine rice". */
  description?: string;
  /** id of the section element, the anchor a category nav links to (href="#noodles"). */
  id?: string;
  /**
   * list: dishes stacked with dividers, the phone layout. grid: dishes as cards, two
   * up when there is room and one up on a phone. Passed on to each MenuItem child.
   * @default "list"
   */
  layout?: "list" | "grid";
  /** Level of the heading element. Each MenuItem's name is one level below. @default 2 */
  headingLevel?: 1 | 2 | 3 | 4 | 5;
  /** The MenuItems. Each becomes an item of a list, so its count is announced. */
  children?: React.ReactNode;
}

export declare function MenuSection(props: MenuSectionProps): React.JSX.Element;

Tokens it reads

Every custom property MenuSection.jsx resolves, read from the source: 3 component, 153 semantic. Component tier first, because that is the one to reach for.

  • component Its own tier. Override one of these and only this component moves.
  • semantic Shared roles. Override one and everything using that role moves with it.
TokenTierDeclared as
--dt-menu-grid-mincomponentcalc(0.4 * var(--dt-size-container-narrow))
--dt-menu-item-dividercomponentvar(--dt-border-subtle)
--dt-menu-section-gapcomponentvar(--dt-space-stack-sm)
--dt-border-width-defaultsemanticvar(--dt-dim-hair)
--dt-space-inline-mdsemanticvar(--dt-dim-4)
--dt-space-stack-2xssemanticvar(--dt-dim-1)
--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)

Source

React only, no dependencies, every value a token.

import React from "react";
import { MenuItem } from "./MenuItem.jsx";

const role = (name) => ({
  fontFamily: `var(--dt-text-${name}-family)`,
  fontSize: `var(--dt-text-${name}-size)`,
  lineHeight: `var(--dt-text-${name}-line)`,
  fontWeight: `var(--dt-text-${name}-weight)`,
  letterSpacing: `var(--dt-text-${name}-tracking)`,
});

/* Two up at most: a column is at least half the row (less the gap) and never
   narrower than --dt-menu-grid-min, so a narrow screen gets one column. */
const GRID_COLUMNS =
  "repeat(auto-fill, minmax(min(100%, max(var(--dt-menu-grid-min), calc((100% - var(--dt-space-inline-md)) / 2))), 1fr))";

export function MenuSection({
  title,
  description,
  id,
  layout = "list",
  headingLevel = 2,
  children,
  style,
  ...rest
}) {
  const grid = layout === "grid";
  const level = Math.min(Math.max(Math.round(headingLevel) || 2, 1), 5);
  const H = `h${level}`;
  const items = React.Children.toArray(children).filter((c) => c != null && c !== false);

  return (
    <section
      id={id}
      style={{ display: "flex", flexDirection: "column", gap: "var(--dt-menu-section-gap)", minWidth: 0, ...style }}
      {...rest}
    >
      <div style={{ display: "flex", flexDirection: "column", gap: "var(--dt-space-stack-2xs)" }}>
        <H style={{ ...role("heading-sm"), margin: 0, color: "var(--dt-text-primary)", overflowWrap: "anywhere" }}>{title}</H>
        {description && (
          <p style={{ ...role("body-sm"), margin: 0, color: "var(--dt-text-secondary)", maxWidth: "70ch" }}>{description}</p>
        )}
      </div>
      {/* A list, so a screen reader announces how many dishes the section
          holds before reading them. */}
      <ul
        role="list"
        style={{
          listStyle: "none", margin: 0, padding: 0, minWidth: 0,
          display: grid ? "grid" : "flex",
          flexDirection: grid ? undefined : "column",
          gridTemplateColumns: grid ? GRID_COLUMNS : undefined,
          gap: grid ? "var(--dt-space-inline-md)" : 0,
        }}
      >
        {items.map((child, i) => {
          const isItem = React.isValidElement(child) && child.type === MenuItem;
          const node = isItem
            ? React.cloneElement(child, {
                layout: child.props.layout || layout,
                headingLevel: child.props.headingLevel || level + 1,
              })
            : child;
          return (
            <li
              key={(React.isValidElement(child) && child.key) || i}
              style={{
                minWidth: 0,
                borderTop: !grid && i > 0 ? "var(--dt-border-width-default) solid var(--dt-menu-item-divider)" : undefined,
              }}
            >
              {node}
            </li>
          );
        })}
      </ul>
    </section>
  );
}