HomeDocsComponentsBlocksChartsThemes
Introduction
  • Introduction
  • Installation
  • Themes
  • llms.txt
Components
  • Accordion
  • Alert
  • Alert Dialog
  • Amount
  • Aspect Ratio
  • Audio Player
  • Avatar
  • Avatar Upload
  • Badge
  • Banner
  • Breadcrumb
  • Button
  • Button Group
  • Calendar
  • Card
  • Carousel
  • Chart
  • Checkbox
  • Code Block
  • Collapsible
  • Color Picker
  • Combobox
  • Command
  • Context Menu
  • Copy Button
  • Countdown
  • Data Grid
  • Data Table
  • Date Picker
  • Date Range Picker
  • Description List
  • Device Mockup
  • Dialog
  • Direction
  • Dot Loader
  • Drawer
  • Dropdown Menu
  • Empty
  • Field
  • File Upload
  • Hover Card
  • Input
  • Input Group
  • Input OTP
  • Item
  • Kanban
  • Kbd
  • Label
  • Menubar
  • Meter
  • Multi Select
  • Native Select
  • Navigation Menu
  • Number Field
  • Pagination
  • Password Strength
  • Phone Input
  • Popover
  • Progress
  • Progress Circle
  • Radio Group
  • Rating
  • Resizable
  • Responsive Dialog
  • Rich Text Editor
  • Scroll Area
  • Segmented Control
  • Select
  • Separator
  • Sheet
  • Sidebar
  • Skeleton
  • Slider
  • Sortable
  • Spinner
  • Stat
  • Stepper
  • Switch
  • Table
  • Tabs
  • Tag Input
  • Terminal
  • Textarea
  • Time Field
  • Timeline
  • Toast
  • Toggle
  • Toggle Group
  • Tooltip
  • Tree
  • Typography
  • Video Player
  • Window Mockup
AI
  • Message Scroller
  • Message
  • Bubble
  • Marker
  • Attachment
  • Questionnaire
  • Composer
  • Reasoning
  • Tool Call
  • Approval Card
  • Citations
Blocks
  • Airtable
  • Airtable Kanban
  • ClickUp List
  • ClickUp Kanban
  • Repeatable Group
  • Command Palette
  • Sign In and Sign Up
  • Availability Scheduler
  • AI Chat
  • Action Bar
  • Notion
  • Banking
  • Lineup Builder
  • Sports Score
  • Strava
  • Sheets
  • Notices
  • Settings
  • Billing
Utilities
  • motion
  • sounds
  • scroll-fade
  • shimmer
Another UI. Built on shadcn/ui with Phosphor icons.

sounds

PreviousNext

Cuelume interaction sounds for every Another UI component, with a switch to turn them off.

Another UI plays Cuelume cues, synthesized live with Web Audio. There are no audio files. Each cue is picked by the job it does.

MomentCue
Buttontap, subtle for ghost, outline, secondary and link
Destructive buttonclose, strong
Typing in an input or textareatype
Menu, select, command or tab optionselect
Checkbox, switch, toggletoggle, backwards when turned off
Menu or popover opens or closesopen or close, subtle
Dialog, sheet or drawer opens or closesopen or close
Accordion or collapsibleopen or close, subtle
Pagination, carouselnavigate, subtle
Route changenavigate
ToastThe cue for what it reports
Drag picked up, dropped, cancelledselect, tap strong, close subtle

One action plays one cue: a menu item plays select, not select and then close. Hover never plays anything.

Turning sounds off#

Sounds are on by default. The Sounds switch on the Themes page turns them off, and the choice is saved in the browser. In your own app, use the hook:

import { useSounds } from "@/lib/sounds"
 
export function SoundSwitch() {
  const [on, setOn] = useSounds()
  return <Switch checked={on} onCheckedChange={setOn} />
}

Installation#

pnpm add cuelume

Copy lib/sounds.ts and components/sound-binder.tsx, then render the binder once in your root layout.

import { SoundBinder } from "@/components/sound-binder"
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <SoundBinder />
        {children}
      </body>
    </html>
  )
}

The binder listens once at the document and reads each component's data-slot, so every component and block gets its cues without changes. Call cue() from @/lib/sounds for moments only your app knows about, such as a save finishing.

components/sound-binder.tsx
"use client"

import * as React from "react"
import { usePathname } from "next/navigation"

import { cue, typeCue } from "@/lib/sounds"

/*
 * Cuelume cues for every primitive, picked by job as the Cuelume guide lays
 * out. Primitives carry data-slot attributes, so one delegated listener and
 * one observer cover every style and every component without touching them.
 */

// Surfaces that announce themselves when they open and close.
const MENUS = [
  "dropdown-menu-content",
  "context-menu-content",
  "menubar-content",
  "popover-content",
  "select-content",
  "combobox-content",
  "navigation-menu-content",
]
const DIALOGS = ["dialog-content", "alert-dialog-content", "sheet-content", "drawer-content"]
const DISCLOSURES = ["accordion-item", "collapsible"]

// Items picked from a list.
const OPTIONS = [
  "dropdown-menu-item",
  "dropdown-menu-checkbox-item",
  "dropdown-menu-radio-item",
  "context-menu-item",
  "context-menu-checkbox-item",
  "context-menu-radio-item",
  "menubar-item",
  "menubar-checkbox-item",
  "menubar-radio-item",
  "select-item",
  "command-item",
  "combobox-item",
  "tabs-trigger",
  "radio-group-item",
  "toggle-group-item",
  "navigation-menu-link",
]
const SWITCHES = ["checkbox", "switch", "toggle"]
const NAV = ["pagination-link", "pagination-previous", "pagination-next", "carousel-previous", "carousel-next"]

const slot = (names: string[]) => names.map((n) => `[data-slot="${n}"]`).join(",")
const OPTION_SEL = slot(OPTIONS)
const SWITCH_SEL = slot(SWITCHES)
const NAV_SEL = slot(NAV)
const MENU_SEL = slot(MENUS)
const DIALOG_SEL = slot(DIALOGS)
const DISCLOSURE_SEL = slot(DISCLOSURES)
const BUTTON_SEL = '[data-slot="button"],[class*="group/button"],button,a[href],[role="button"]'

function isOn(el: Element) {
  const state = el.getAttribute("data-state")
  return (
    el.getAttribute("aria-checked") === "true" ||
    el.getAttribute("aria-pressed") === "true" ||
    state === "checked" ||
    state === "on" ||
    el.hasAttribute("data-checked") ||
    el.hasAttribute("data-pressed")
  )
}

function isOpen(el: Element) {
  const state = el.getAttribute("data-state")
  if (state) return state === "open"
  return el.hasAttribute("data-open")
}

// Taps wait a moment, so a button that opens or closes a dialog plays the
// dialog's cue instead of a tap followed by it.
let pendingTap: ReturnType<typeof setTimeout> | undefined
function tapSoon(options: Parameters<typeof cue>[1]) {
  clearTimeout(pendingTap)
  pendingTap = setTimeout(() => cue("tap", options), 24)
}
function surfaceCue(...args: Parameters<typeof cue>) {
  clearTimeout(pendingTap)
  cue(args[0], { ...args[1], force: true })
}

function onClick(e: MouseEvent) {
  const target = e.target as Element | null
  if (!target?.closest) return
  const input = e.detail === 0 ? "keyboard" : pointer

  const sw = target.closest(SWITCH_SEL)
  if (sw) {
    // Read the state once the component has updated.
    requestAnimationFrame(() => cue("toggle", { direction: isOn(sw) ? "forward" : "back" }))
    return
  }

  const option = target.closest(OPTION_SEL)
  if (option) {
    const list = option.parentElement ? [...option.parentElement.querySelectorAll(OPTION_SEL)] : []
    const before = list.findIndex((el) => el.getAttribute("data-state") === "active" || el.getAttribute("aria-selected") === "true")
    const after = list.indexOf(option)
    cue("select", { direction: before >= 0 && after < before ? "back" : "forward" })
    return
  }

  const nav = target.closest(NAV_SEL)
  if (nav) {
    const back = /previous/.test(nav.getAttribute("data-slot") ?? "")
    cue("navigate", { emphasis: "subtle", direction: back ? "back" : "forward" })
    return
  }

  const button = target.closest(BUTTON_SEL)
  if (!button || button.matches(":disabled,[aria-disabled='true']")) return
  // Triggers stay quiet. The surface they open plays open or close instead.
  if (button.hasAttribute("aria-haspopup") || button.hasAttribute("aria-expanded")) return
  // Draggable items play their own pick up and drop cues.
  if (button.getAttribute("aria-roledescription") === "sortable") return
  // Links that change the route play navigate when the route changes.
  if (button.matches("a[href]") && !button.getAttribute("href")?.startsWith("#")) return
  const variant = button.getAttribute("data-variant")
  if (variant === "destructive") return cue("close", { emphasis: "strong" })
  const quiet = variant === "ghost" || variant === "outline" || variant === "link" || variant === "secondary"
  tapSoon({ emphasis: quiet ? "subtle" : "normal", input })
}

let pointer: "mouse" | "touch" | "pen" = "mouse"
function onPointerDown(e: PointerEvent) {
  if (e.pointerType === "mouse" || e.pointerType === "touch" || e.pointerType === "pen") pointer = e.pointerType
}

// Select items pick on pointer up and close before any click, so they are
// heard here, and from Enter or Space on a highlighted item.
function onPointerUp(e: PointerEvent) {
  if ((e.target as Element | null)?.closest?.('[data-slot="select-item"]')) cue("select")
}
function onSelectKey(e: KeyboardEvent) {
  if ((e.key === "Enter" || e.key === " ") && (e.target as Element | null)?.closest?.('[data-slot="select-content"]'))
    cue("select")
}

function onKeyDown(e: KeyboardEvent) {
  const el = e.target as HTMLElement | null
  if (!el || e.metaKey || e.ctrlKey || e.altKey) return
  const editable =
    (el instanceof HTMLInputElement && !["password", "checkbox", "radio", "range", "button", "submit"].includes(el.type)) ||
    el instanceof HTMLTextAreaElement ||
    el.isContentEditable
  if (!editable) return
  if (e.key === "Backspace" || e.key === "Delete") typeCue("delete")
  else if (e.key === "Enter") typeCue("enter")
  else if (e.key === " ") typeCue("space")
  else if (e.key.length === 1) typeCue("printable")
}

/* Error states: a control that turns invalid shakes (transitions.dev error
   state shake) and the error cue plays once, however many fields failed. */
function shake(el: Element) {
  const target = el.closest('[data-slot="input-group"]') ?? el
  if (!(target instanceof HTMLElement)) return
  target.removeAttribute("data-shaking")
  void target.offsetWidth
  target.setAttribute("data-shaking", "")
  setTimeout(() => target.removeAttribute("data-shaking"), 320)
}

function flagErrors(els: Element[]) {
  if (!els.length) return
  els.forEach(shake)
  cue("error", { emphasis: "subtle", force: true })
}

function onSubmit(e: SubmitEvent) {
  const form = e.target
  if (!(form instanceof HTMLFormElement)) return
  // Wait for validation to mark fields, then shake whatever is still invalid.
  requestAnimationFrame(() => flagErrors([...form.querySelectorAll('[aria-invalid="true"]')]))
}

let invalidBatch: Element[] = []
function onInvalid(e: Event) {
  if (!(e.target instanceof Element)) return
  invalidBatch.push(e.target)
  if (invalidBatch.length === 1)
    queueMicrotask(() => {
      flagErrors(invalidBatch)
      invalidBatch = []
    })
}

const seen = new WeakMap<Element, boolean>()

function announce(el: Element) {
  const open = isOpen(el)
  if (seen.get(el) === open) return
  const first = !seen.has(el)
  seen.set(el, open)
  if (first && !open) return
  if (el.matches(DIALOG_SEL)) surfaceCue(open ? "open" : "close")
  // A menu closing right after an item was picked stays quiet.
  else if (el.matches(MENU_SEL)) (open ? surfaceCue : cue)(open ? "open" : "close", { emphasis: "subtle" })
  else if (el.matches(DISCLOSURE_SEL) && !first) surfaceCue(open ? "open" : "close", { emphasis: "subtle" })
}

function scan(node: Node) {
  if (!(node instanceof Element)) return
  for (const el of [node, ...node.querySelectorAll(DISCLOSURE_SEL)])
    if (el.matches(DISCLOSURE_SEL) && !seen.has(el)) seen.set(el, isOpen(el))
  const all = [node, ...node.querySelectorAll(`${MENU_SEL},${DIALOG_SEL}`)]
  for (const el of all) if (el.matches(`${MENU_SEL},${DIALOG_SEL}`)) announce(el)
  if (node.matches("[data-sonner-toast]")) {
    const type = node.getAttribute("data-type")
    const name = type === "success" ? "success" : type === "error" ? "error" : type === "warning" ? "warning" : "ready"
    cue(name, { emphasis: "subtle", force: true })
  }
}

export function SoundBinder() {
  const pathname = usePathname()
  const first = React.useRef(true)

  React.useEffect(() => {
    if (first.current) {
      first.current = false
      return
    }
    cue("navigate", { force: true })
  }, [pathname])

  React.useEffect(() => {
    // Disclosures already on the page are recorded, so only changes play.
    document.querySelectorAll(DISCLOSURE_SEL).forEach((el) => seen.set(el, isOpen(el)))
    const observer = new MutationObserver((records) => {
      const invalid: Element[] = []
      for (const r of records) {
        if (r.type === "attributes" && r.attributeName === "aria-invalid") {
          if (r.target instanceof Element && r.target.getAttribute("aria-invalid") === "true" && r.oldValue !== "true")
            invalid.push(r.target)
          continue
        }
        if (r.type === "childList") r.addedNodes.forEach(scan)
        else if (r.target instanceof Element && r.target.matches(`${MENU_SEL},${DIALOG_SEL},${DISCLOSURE_SEL}`))
          announce(r.target)
      }
      flagErrors(invalid)
    })
    observer.observe(document.body, {
      subtree: true,
      childList: true,
      attributes: true,
      attributeFilter: ["data-state", "data-open", "data-closed", "aria-invalid"],
      attributeOldValue: true,
    })
    document.addEventListener("submit", onSubmit, true)
    document.addEventListener("invalid", onInvalid, true)
    document.addEventListener("pointerdown", onPointerDown, true)
    document.addEventListener("click", onClick)
    document.addEventListener("keydown", onKeyDown, true)
    document.addEventListener("pointerup", onPointerUp, true)
    document.addEventListener("keydown", onSelectKey, true)
    return () => {
      document.removeEventListener("submit", onSubmit, true)
      document.removeEventListener("invalid", onInvalid, true)
      document.removeEventListener("pointerup", onPointerUp, true)
      document.removeEventListener("keydown", onSelectKey, true)
      observer.disconnect()
      document.removeEventListener("pointerdown", onPointerDown, true)
      document.removeEventListener("click", onClick)
      document.removeEventListener("keydown", onKeyDown, true)
    }
  }, [])

  return null
}
lib/sounds.ts
"use client"

import * as React from "react"
import { play, setEnabled, type PlayOptions, type SoundName } from "cuelume"

// The app owns the sound preference. Cuelume only plays.
const KEY = "another-ui:sounds"
const listeners = new Set<() => void>()
let enabled = true
let lastCue = 0

function read() {
  try {
    return window.localStorage.getItem(KEY) !== "off"
  } catch {
    return true
  }
}

if (typeof window !== "undefined") {
  enabled = read()
  setEnabled(enabled)
}

export function soundsEnabled() {
  return enabled
}

export function setSoundsEnabled(next: boolean) {
  enabled = next
  setEnabled(next)
  try {
    window.localStorage.setItem(KEY, next ? "on" : "off")
  } catch {
    // Storage can be blocked. The setting still holds for this visit.
  }
  listeners.forEach((l) => l())
}

export function useSounds() {
  const on = React.useSyncExternalStore(
    (l) => {
      listeners.add(l)
      return () => listeners.delete(l)
    },
    () => enabled,
    () => true
  )
  return [on, setSoundsEnabled] as const
}

/**
 * Plays one cue per action. A cue that lands within 90ms of another is
 * dropped, so a menu item does not also play the menu closing, and a trigger
 * does not also play its menu opening. Typing is exempt and throttles itself.
 */
export function cue(name: SoundName, options?: PlayOptions & { force?: boolean }) {
  if (!enabled) return
  const now = performance.now()
  if (!options?.force && now - lastCue < 90) return
  lastCue = now
  const { force: _force, ...rest } = options ?? {}
  play(name, rest)
}

let lastType = 0
export function typeCue(key: NonNullable<PlayOptions["key"]>) {
  if (!enabled) return
  const now = performance.now()
  if (now - lastType < 40) return
  lastType = now
  play("type", { key })
}
motionscroll-fade

On This Page

Turning sounds offInstallation