---
title: Scale menu
description: "A menu that picks a root and a scale and turns the scale on or off, in parts you place beside items of your own."
component: true
base: base
---

```tsx
"use client";

import { inScale, type Scale } from "@audio-ui/core/pitch";
import { useState } from "react";
import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
import { Piano } from "@/components/audio-ui/piano";
import {
  ScaleMenu,
  ScaleMenuContent,
  ScaleMenuName,
  ScaleMenuRoot,
  ScaleMenuToggle,
  ScaleMenuTrigger,
} from "@/components/audio-ui/scale-menu";

const MIN_PITCH = 48;
const MAX_PITCH = 72;
const PITCHES = Array.from(
  { length: MAX_PITCH - MIN_PITCH + 1 },
  (_, index) => MIN_PITCH + index
);

/** Pick a root and a scale: the keys in it light up. */
export function ScaleMenuDemo() {
  const [enabled, setEnabled] = useState(true);
  const [scale, setScale] = useState<Scale>({ name: "major", root: 2 });
  const marked = enabled
    ? PITCHES.filter((pitch) => inScale(pitch, scale))
    : [];

  return (
    <div className="flex w-full flex-col items-center gap-4">
      <ScaleMenu
        enabled={enabled}
        onEnabledChange={setEnabled}
        onScaleChange={setScale}
        scale={scale}
      >
        <ScaleMenuTrigger />
        <ScaleMenuContent align="center">
          <ScaleMenuToggle>Light the scale</ScaleMenuToggle>
          <DropdownMenuSeparator />
          <ScaleMenuRoot />
          <ScaleMenuName />
        </ScaleMenuContent>
      </ScaleMenu>
      <Piano
        activeNotes={marked}
        aria-label="Piano"
        maxPitch={MAX_PITCH}
        minPitch={MIN_PITCH}
      />
    </div>
  );
}
```

## Installation

<CodeTabs>

<TabsList>
  <TabsTrigger value="cli">CLI</TabsTrigger>
  <TabsTrigger value="manual">Manual</TabsTrigger>
</TabsList>

<TabsContent value="cli">

```bash component
npx shadcn@latest add @audio-ui/scale-menu
```

</TabsContent>

<TabsContent value="manual">

<Steps>

<Step>Install the following dependencies:</Step>

```bash
npm install @audio-ui/core
```

<Step>Copy and paste the following code into your project.</Step>

```tsx
"use client";

import {
  pitchClassName,
  SCALES,
  type Scale,
  type ScaleName,
} from "@audio-ui/core/pitch";
import {
  type ComponentProps,
  createContext,
  type ReactNode,
  use,
  useCallback,
  useMemo,
} from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuSub,
  DropdownMenuSubContent,
  DropdownMenuSubTrigger,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";

/** Each scale's name, as the menu writes it. */
export const SCALE_LABELS: Record<ScaleName, string> = {
  blues: "Blues",
  chromatic: "Chromatic",
  dorian: "Dorian",
  "harmonic-minor": "Harmonic minor",
  locrian: "Locrian",
  lydian: "Lydian",
  major: "Major",
  "major-pentatonic": "Major pentatonic",
  "melodic-minor": "Melodic minor",
  minor: "Minor",
  "minor-pentatonic": "Minor pentatonic",
  mixolydian: "Mixolydian",
  phrygian: "Phrygian",
};
const SCALE_NAMES = Object.keys(SCALES) as ScaleName[];
const ROOTS = Array.from({ length: 12 }, (_, root) => root);

/** "D Dorian": a scale's root and its name. */
export const scaleLabel = (scale: Scale) =>
  `${pitchClassName(scale.root)} ${SCALE_LABELS[scale.name]}`;

interface ScaleMenuContextValue {
  enabled: boolean;
  onEnabledChange: (enabled: boolean) => void;
  onScaleChange: (scale: Scale) => void;
  scale: Scale;
}

const ScaleMenuContext = createContext<ScaleMenuContextValue | null>(null);

function useScaleMenu() {
  const context = use(ScaleMenuContext);
  if (!context) {
    throw new Error("ScaleMenu parts must be placed inside a ScaleMenu.");
  }
  return context;
}

interface ScaleMenuProps {
  children: ReactNode;
  /** Whether the scale is in use, such as marked on a roll's rows. */
  enabled: boolean;
  onEnabledChange: (enabled: boolean) => void;
  /** Picking a root or a scale also turns it on. */
  onScaleChange: (scale: Scale) => void;
  /** The root and the scale, kept while the scale is off. */
  scale: Scale;
}

/** A menu that picks a root and a scale, and turns the scale on or off. */
function ScaleMenu({
  children,
  enabled,
  onEnabledChange,
  onScaleChange,
  scale,
}: ScaleMenuProps) {
  const value = useMemo(
    () => ({ enabled, onEnabledChange, onScaleChange, scale }),
    [enabled, onEnabledChange, onScaleChange, scale]
  );
  return (
    <ScaleMenuContext value={value}>
      <DropdownMenu>{children}</DropdownMenu>
    </ScaleMenuContext>
  );
}

/** The button that opens the menu: filled and named after the scale when it is on, "Scale" when off. */
function ScaleMenuTrigger(
  props: Omit<ComponentProps<typeof Button>, "children" | "variant">
) {
  const { enabled, scale } = useScaleMenu();
  return (
    <DropdownMenuTrigger
      render={
        <Button
          aria-label={`Scale: ${enabled ? scaleLabel(scale) : "off"}`}
          data-slot="scale-menu-trigger"
          variant={enabled ? "default" : "outline"}
          {...props}
        />
      }
    >
      {enabled ? scaleLabel(scale) : "Scale"}
    </DropdownMenuTrigger>
  );
}

function ScaleMenuContent({
  align = "end",
  className,
  ...props
}: ComponentProps<typeof DropdownMenuContent>) {
  return (
    <DropdownMenuContent
      align={align}
      className={cn("w-auto min-w-48", className)}
      data-slot="scale-menu-content"
      {...props}
    />
  );
}

/** A checkbox item that turns the scale on or off. Its children are the label. */
function ScaleMenuToggle({
  children = "Mark the scale",
  ...props
}: Omit<
  ComponentProps<typeof DropdownMenuCheckboxItem>,
  "checked" | "onCheckedChange"
>) {
  const { enabled, onEnabledChange } = useScaleMenu();
  return (
    <DropdownMenuCheckboxItem
      checked={enabled}
      data-slot="scale-menu-toggle"
      onCheckedChange={onEnabledChange}
      {...props}
    >
      {children}
    </DropdownMenuCheckboxItem>
  );
}

/** A submenu of the twelve roots. */
function ScaleMenuRoot() {
  const { onEnabledChange, onScaleChange, scale } = useScaleMenu();
  const changeRoot = useCallback(
    (root: number) => {
      onScaleChange({ ...scale, root });
      onEnabledChange(true);
    },
    [onEnabledChange, onScaleChange, scale]
  );
  return (
    <DropdownMenuSub>
      <DropdownMenuSubTrigger data-slot="scale-menu-root">
        Root: {pitchClassName(scale.root)}
      </DropdownMenuSubTrigger>
      <DropdownMenuSubContent>
        <DropdownMenuRadioGroup onValueChange={changeRoot} value={scale.root}>
          {ROOTS.map((root) => (
            <DropdownMenuRadioItem key={root} value={root}>
              {pitchClassName(root)}
            </DropdownMenuRadioItem>
          ))}
        </DropdownMenuRadioGroup>
      </DropdownMenuSubContent>
    </DropdownMenuSub>
  );
}

/** A submenu of the scales, by name. */
function ScaleMenuName() {
  const { onEnabledChange, onScaleChange, scale } = useScaleMenu();
  const changeName = useCallback(
    (name: ScaleName) => {
      onScaleChange({ ...scale, name });
      onEnabledChange(true);
    },
    [onEnabledChange, onScaleChange, scale]
  );
  return (
    <DropdownMenuSub>
      <DropdownMenuSubTrigger data-slot="scale-menu-name">
        Scale: {SCALE_LABELS[scale.name]}
      </DropdownMenuSubTrigger>
      <DropdownMenuSubContent>
        <DropdownMenuRadioGroup onValueChange={changeName} value={scale.name}>
          {SCALE_NAMES.map((name) => (
            <DropdownMenuRadioItem key={name} value={name}>
              {SCALE_LABELS[name]}
            </DropdownMenuRadioItem>
          ))}
        </DropdownMenuRadioGroup>
      </DropdownMenuSubContent>
    </DropdownMenuSub>
  );
}

export {
  ScaleMenu,
  ScaleMenuContent,
  ScaleMenuName,
  ScaleMenuRoot,
  ScaleMenuToggle,
  ScaleMenuTrigger,
};
```

<Step>Update the import paths to match your project setup.</Step>

</Steps>

</TabsContent>

</CodeTabs>

## Anatomy

```tsx
<ScaleMenu>
  <ScaleMenuTrigger />
  <ScaleMenuContent>
    <ScaleMenuToggle />
    <ScaleMenuRoot />
    <ScaleMenuName />
  </ScaleMenuContent>
</ScaleMenu>
```

## Usage

```tsx
import type { Scale } from "@audio-ui/core/pitch";
import {
  ScaleMenu,
  ScaleMenuContent,
  ScaleMenuName,
  ScaleMenuRoot,
  ScaleMenuToggle,
  ScaleMenuTrigger,
} from "@/components/audio-ui/scale-menu";
import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";

const [enabled, setEnabled] = useState(false);
const [scale, setScale] = useState<Scale>({ name: "major", root: 0 });

<ScaleMenu
  enabled={enabled}
  onEnabledChange={setEnabled}
  onScaleChange={setScale}
  scale={scale}
>
  <ScaleMenuTrigger />
  <ScaleMenuContent>
    <ScaleMenuToggle />
    <DropdownMenuSeparator />
    <ScaleMenuRoot />
    <ScaleMenuName />
  </ScaleMenuContent>
</ScaleMenu>;
```

A scale is `{ root, name }`: `root` is a pitch class, 0 for C to 11 for B, and `name` one of the scales in `SCALES` from `@audio-ui/core/pitch` (`"major"`, `"minor"`, `"dorian"`, `"blues"`…). The menu keeps the scale while it is off, so turning it back on brings back the last one; picking a root or a scale turns it on.

Pass `enabled ? scale : null` to whatever marks the scale: a [Piano roll](/docs/components/base/piano-roll)'s `scale` marks its rows, and `inScale(pitch, scale)` from `@audio-ui/core/pitch` tells whether any pitch is in it.

### Items of your own

`ScaleMenuContent` is a dropdown menu's content, so any item goes beside the parts. The piano roll block adds a checkbox that folds the roll onto the scale's rows, and shows each item's key:

```tsx
<ScaleMenuContent>
  <ScaleMenuToggle>
    Mark the scale
    <DropdownMenuShortcut>K</DropdownMenuShortcut>
  </ScaleMenuToggle>
  <DropdownMenuCheckboxItem checked={folded} onCheckedChange={setFolded}>
    Only its rows
    <DropdownMenuShortcut>G</DropdownMenuShortcut>
  </DropdownMenuCheckboxItem>
  <DropdownMenuSeparator />
  <ScaleMenuRoot />
  <ScaleMenuName />
</ScaleMenuContent>
```

## API reference

### ScaleMenu

<TypeTable>
<TypeRow name={"scale"} type={"{ root, name }"}>The root and the scale, kept while the scale is off.</TypeRow>
<TypeRow name={"onScaleChange"} type={"(scale) => void"}>A root or a scale was picked. Picking one also turns the scale on.</TypeRow>
<TypeRow name={"enabled"} type={"boolean"}>Whether the scale is in use.</TypeRow>
<TypeRow name={"onEnabledChange"} type={"(enabled) => void"}>The scale was turned on or off.</TypeRow>
</TypeTable>

### Parts

<TypeTable title="Components">
<TypeRow name={"ScaleMenuTrigger"}>The button that opens the menu: filled and named after the scale ("D Dorian") when it is on, outlined and named "Scale" when off. Takes the button's props.</TypeRow>
<TypeRow name={"ScaleMenuContent"}>The menu. Aligned to the trigger's end unless given `align`.</TypeRow>
<TypeRow name={"ScaleMenuToggle"}>A checkbox item that turns the scale on or off. Its children are the label, "Mark the scale" when left out.</TypeRow>
<TypeRow name={"ScaleMenuRoot"}>A submenu of the twelve roots.</TypeRow>
<TypeRow name={"ScaleMenuName"}>A submenu of the scales.</TypeRow>
</TypeTable>

The file also exports `SCALE_LABELS`, each scale's name as the menu writes it, and `scaleLabel(scale)`, which writes a scale as "D Dorian".
