FulfilmentToggle

A segmented control for how an order reaches its buyer, delivery or pickup, with radio semantics and an optional detail line under each segment.

Commerce FulfilmentToggle.jsx FulfilmentToggle.d.ts FulfilmentToggle.md

Playground

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

Install and import

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

npm install @dovetail-ds/react react react-dom
import { FulfilmentToggle } 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 segmented control for how an order reaches its buyer, delivery or pickup, with radio semantics and an optional detail line under each segment.

Use it when

  • A store page or checkout lets people switch between delivery and pickup, and the page's times and fees depend on the choice.
  • There are two or three ways to receive an order: add "Dine in" through options.

Don't use it when

  • It switches views of content. Use Tabs.
  • There are more than three choices, or the choices need descriptions. Use RadioGroup.
  • It is an on/off setting. Use Switch.

Example

const [how, setHow] = React.useState("delivery");

<FulfilmentToggle
  label="How to get your order"
  value={how}
  onChange={setHow}
  options={[
    { value: "delivery", label: "Delivery", detail: "25–35 min" },
    { value: "pickup", label: "Pickup", detail: "Ready in 15" },
  ]}
/>

Variants

PropWhat it is for
optionsThe segments. Defaults to Delivery and Pickup with no detail. Each takes a detail line for a time or a fee.
fullWidthOn by default: the segments share the container's width, which is what a phone wants. false sizes it to its segments.
disabledTurns every segment off, for a store that offers only one way. Prefer hiding the toggle when there is no choice.

Composition

Sits under the StoreHeader on a store page, or at the top of a checkout. Controlled: the choice lives in your app, which changes the deliveryTime and deliveryFee it passes to StoreHeader and the totals it shows.

Tokens

Tier 3, in tokens/component/commerce.css; the colour ones are repeated under .dark:

  • --dt-fulfilment-track-bg (--dt-surface-sunken), --dt-fulfilment-thumb-bg (--dt-surface-raised), --dt-fulfilment-thumb-shadow (--dt-elevation-1).
  • --dt-fulfilment-fg (--dt-text-secondary), --dt-fulfilment-fg-selected (--dt-text-primary), --dt-fulfilment-detail-color (--dt-text-tertiary), --dt-fulfilment-detail-color-selected (--dt-text-secondary).
  • --dt-fulfilment-inset (--dt-space-inset-2xs), --dt-fulfilment-radius (--dt-radius-container), --dt-fulfilment-thumb-radius (--dt-radius-media).

Each segment is at least --dt-size-touch-target tall. The label is --dt-text-label-md-*, semibold when chosen; the detail is --dt-text-body-xs-*. Motion is --dt-motion-micro, which is instant under reduced motion.

Accessibility

  • A role="radiogroup" named by the required label; each segment is a role="radio" button with aria-checked.
  • One tab stop, on the chosen segment. The arrow keys move and choose together and wrap at the ends; Home and End jump to the first and last; in a right-to-left page the horizontal arrows follow the reading direction.
  • The chosen segment is marked by a raised surface and a heavier label, and announced as checked.
  • The detail line is part of each segment's name: "Delivery 25–35 min".

Content

  • Labels are one word, sentence case: "Delivery", "Pickup", "Dine in".
  • Details are short and concrete: "25–35 min", "Ready in 15", "Free".
  • The group label is a question or a noun phrase: "How to get your order".

Props

The typed contract, from FulfilmentToggle.d.ts.

import * as React from "react";

/** One way to receive the order. */
export interface FulfilmentOption {
  /** The value reported to onChange. Unique within the toggle. */
  value: string;
  /** The segment's label: "Delivery", "Pickup", "Dine in". */
  label: string;
  /** A short second line under the label: "25–35 min", "Ready in 15". */
  detail?: string;
}

/** Delivery or pickup: a segmented control with radio semantics, full width by default. */
export interface FulfilmentToggleProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange" | "role" | "children" | "defaultValue"> {
  /** The chosen option's value. Controlled. With the default options, "delivery" or "pickup". */
  value: "delivery" | "pickup" | (string & {});
  /** Called with the newly chosen value, from a click or an arrow key. */
  onChange: (value: string) => void;
  /** The radio group's accessible name, e.g. "How to get your order". */
  label: string;
  /** The segments. @default [{ value: "delivery", label: "Delivery" }, { value: "pickup", label: "Pickup" }] */
  options?: FulfilmentOption[];
  /** Fill the container's width. false sizes it to its segments. @default true */
  fullWidth?: boolean;
  /** Disables every segment. */
  disabled?: boolean;
}

export declare function FulfilmentToggle(props: FulfilmentToggleProps): React.JSX.Element;

Tokens it reads

Every custom property FulfilmentToggle.jsx resolves, read from the source: 10 component, 154 semantic, 1 primitive. 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.
  • primitive Raw values. A component reading one directly is a deliberate exception.
TokenTierDeclared as
--dt-fulfilment-detail-colorcomponentvar(--dt-text-tertiary)
--dt-fulfilment-detail-color-selectedcomponentvar(--dt-text-secondary)
--dt-fulfilment-fgcomponentvar(--dt-text-secondary)
--dt-fulfilment-fg-selectedcomponentvar(--dt-text-primary)
--dt-fulfilment-insetcomponentvar(--dt-space-inset-2xs)
--dt-fulfilment-radiuscomponentvar(--dt-radius-container)
--dt-fulfilment-thumb-bgcomponentvar(--dt-surface-raised)
--dt-fulfilment-thumb-radiuscomponentvar(--dt-radius-media)
--dt-fulfilment-thumb-shadowcomponentvar(--dt-elevation-1)
--dt-fulfilment-track-bgcomponentvar(--dt-surface-sunken)
--dt-motion-microsemanticvar(--dt-duration-100) var(--dt-easing-standard)
--dt-size-touch-targetsemanticvar(--dt-dim-11)
--dt-space-inset-smsemanticvar(--dt-dim-3)
--dt-space-inset-xssemanticvar(--dt-dim-2)
--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-font-weight-semiboldprimitive600

Source

React only, no dependencies, every value a token.

import React from "react";

const DEFAULT_OPTIONS = [
  { value: "delivery", label: "Delivery" },
  { value: "pickup", label: "Pickup" },
];

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)`,
});

/* A segmented control with radio semantics: one tab stop, on the chosen
   segment, and arrow keys that move and choose together, the way a native
   radio group behaves. */
export function FulfilmentToggle({
  value,
  onChange,
  label,
  options = DEFAULT_OPTIONS,
  fullWidth = true,
  disabled = false,
  style,
  ...rest
}) {
  const refs = React.useRef([]);
  const [hover, setHover] = React.useState(null);
  const index = options.findIndex((o) => o.value === value);
  const tabStop = index >= 0 ? index : 0;

  const choose = (i) => {
    const opt = options[i];
    if (!opt || disabled) return;
    if (opt.value !== value) onChange && onChange(opt.value);
    const el = refs.current[i];
    if (el) el.focus();
  };

  const onKeyDown = (e, i) => {
    const rtl = !!(e.currentTarget.closest && e.currentTarget.closest('[dir="rtl"]'));
    const fwd = rtl ? "ArrowLeft" : "ArrowRight";
    const back = rtl ? "ArrowRight" : "ArrowLeft";
    const n = options.length;
    let next = null;
    if (e.key === fwd || e.key === "ArrowDown") next = (i + 1) % n;
    else if (e.key === back || e.key === "ArrowUp") next = (i - 1 + n) % n;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = n - 1;
    else if (e.key === " " || e.key === "Enter") next = i;
    if (next == null) return;
    e.preventDefault();
    choose(next);
  };

  return (
    <div
      role="radiogroup"
      aria-label={label}
      aria-disabled={disabled || undefined}
      style={{
        display: fullWidth ? "grid" : "inline-grid",
        gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))`,
        gap: "var(--dt-fulfilment-inset)",
        boxSizing: "border-box",
        width: fullWidth ? "100%" : undefined,
        padding: "var(--dt-fulfilment-inset)",
        borderRadius: "var(--dt-fulfilment-radius)",
        background: "var(--dt-fulfilment-track-bg)",
        ...style,
      }}
      {...rest}
    >
      {options.map((opt, i) => {
        const on = i === index;
        return (
          <button
            key={opt.value}
            ref={(el) => { refs.current[i] = el; }}
            type="button"
            role="radio"
            aria-checked={on}
            tabIndex={i === tabStop ? 0 : -1}
            disabled={disabled}
            onClick={() => choose(i)}
            onKeyDown={(e) => onKeyDown(e, i)}
            onMouseEnter={() => setHover(i)}
            onMouseLeave={() => setHover(null)}
            style={{
              appearance: "none", border: 0, margin: 0, minWidth: 0,
              display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
              gap: 0,
              minHeight: "var(--dt-size-touch-target)",
              padding: "var(--dt-space-inset-xs) var(--dt-space-inset-sm)",
              borderRadius: "var(--dt-fulfilment-thumb-radius)",
              background: on ? "var(--dt-fulfilment-thumb-bg)" : "transparent",
              boxShadow: on ? "var(--dt-fulfilment-thumb-shadow)" : "none",
              color: disabled
                ? "var(--dt-text-disabled)"
                : on || hover === i ? "var(--dt-fulfilment-fg-selected)" : "var(--dt-fulfilment-fg)",
              cursor: disabled ? "not-allowed" : "pointer",
              textAlign: "center",
              transition: "background var(--dt-motion-micro), box-shadow var(--dt-motion-micro), color var(--dt-motion-micro)",
            }}
          >
            <span style={{ ...role("label-md"), fontWeight: on ? "var(--dt-font-weight-semibold)" : "var(--dt-text-label-md-weight)",maxWidth: "100%", overflowWrap: "anywhere" }}>
              {opt.label}
            </span>
            {opt.detail && (
              <span
                style={{
                  ...role("body-xs"), maxWidth: "100%", overflowWrap: "anywhere",
                  color: disabled
                    ? "var(--dt-text-disabled)"
                    : on ? "var(--dt-fulfilment-detail-color-selected)" : "var(--dt-fulfilment-detail-color)",
                }}
              >
                {opt.detail}
              </span>
            )}
          </button>
        );
      })}
    </div>
  );
}