Subframe
Components & interactionsNested drill-down menu

Layered menu

A compact archive navigation slides between category and subcategory panels without multiplying popovers.

Open a category, drill into a group and choose a collection.

Subframe Original · MIT

Add to your project

Component source

Standalone React component. Styles are included.

React only. No additional packages.

Render the default export inside a container with a defined height. Its optional active prop stops movement when false. This example works with React 19; Next.js projects can use it as a client component.

View source code
/**
 * MIT License
 *
 * Copyright (c) 2026 Subframe
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

"use client"

import { useEffect, useId, useLayoutEffect, useRef, useState } from "react"
const groups = [
  {
    name: "Collection",
    items: [
      ["Photography", "Portraits", "Architecture", "Landscape"],
      ["Print & paper", "Woodblock", "Botanical", "Typography"],
    ],
  },
  {
    name: "Explore",
    items: [
      ["By period", "1800–1899", "1900–1949", "1950–today"],
      ["By medium", "Photograph", "Print", "Object"],
    ],
  },
]
export default function LayeredMenu({ active = true }: { active?: boolean }) {
  const panelId = useId()
  const [open, setOpen] = useState<number | null>(0),
    [level, setLevel] = useState<number | null>(null),
    [selected, setSelected] = useState("Architecture"),
    [instant, setInstant] = useState(false),
    root = useRef<HTMLElement>(null),
    triggers = useRef<(HTMLButtonElement | null)[]>([])
  const previousLevel = useRef<number | null>(null)
  useLayoutEffect(() => {
    if (level !== null) root.current?.querySelector<HTMLButtonElement>(".lm-back")?.focus({ preventScroll: true })
    else if (previousLevel.current !== null)
      root.current
        ?.querySelectorAll<HTMLButtonElement>(".lm-page")[0]
        ?.querySelectorAll("button")
        [previousLevel.current]?.focus({ preventScroll: true })
    previousLevel.current = level
  }, [level])
  useEffect(() => {
    const outside = (e: PointerEvent) => {
      if (!root.current?.contains(e.target as Node)) {
        setOpen(null)
        setLevel(null)
      }
    }
    document.addEventListener("pointerdown", outside)
    return () => document.removeEventListener("pointerdown", outside)
  }, [])
  const close = () => {
    const i = open
    setOpen(null)
    setLevel(null)
    if (i !== null) triggers.current[i]?.focus({ preventScroll: true })
  }
  const choose = (value: string) => {
    setSelected(value)
    close()
  }
  return (
    <section
      ref={root}
      className="sf-layered-menu"
      inert={!active}
      data-still={!active || instant}
      onKeyDown={(e) => {
        if (e.key === "Escape") {
          setInstant(true)
          level !== null ? setLevel(null) : close()
        }
      }}
      onBlur={(e) => {
        if (e.relatedTarget && !e.currentTarget.contains(e.relatedTarget as Node)) {
          setOpen(null)
          setLevel(null)
        }
      }}
    >
      <header>
        <nav aria-label="Archive navigation">
          {groups.map((g, i) => (
            <button
              type="button"
              key={g.name}
              ref={(el) => {
                triggers.current[i] = el
              }}
              aria-expanded={open === i}
              aria-controls={panelId}
              onClick={(e) => {
                setInstant(e.detail === 0)
                setOpen(open === i ? null : i)
                setLevel(null)
              }}
            >
              {g.name}
              <span aria-hidden="true">⌄</span>
            </button>
          ))}
        </nav>
      </header>
      <div className="lm-background">
        <span>Selected collection</span>
        <strong>{selected}</strong>
      </div>
      {open !== null && (
        <div className="lm-panel" id={panelId}>
          <div className="lm-track" data-deep={level !== null}>
            <div className="lm-page" inert={level !== null}>
              <small>{groups[open].name}</small>
              {groups[open].items.map((g, i) => (
                <button
                  type="button"
                  key={g[0]}
                  onClick={(e) => {
                    setInstant(e.detail === 0)
                    setLevel(i)
                  }}
                >
                  <span className="lm-glyph">{i ? "▦" : "◫"}</span>
                  <span>
                    {g[0]}
                    <small>{g.slice(1).join(" · ")}</small>
                  </span>
                  <b aria-hidden="true">→</b>
                </button>
              ))}
            </div>
            <div className="lm-page" inert={level === null}>
              <button
                type="button"
                className="lm-back"
                onClick={(e) => {
                  setInstant(e.detail === 0)
                  setLevel(null)
                }}
              >
                ← <span>{level !== null ? groups[open].items[level][0] : ""}</span>
              </button>
              {level !== null &&
                groups[open].items[level].slice(1).map((v) => (
                  <button type="button" key={v} className="lm-leaf" onClick={() => choose(v)}>
                    <span>{v}</span>
                    {selected === v && <i />}
                    <b aria-hidden="true">↗</b>
                  </button>
                ))}
            </div>
          </div>
        </div>
      )}
      <style>{`.sf-layered-menu{width:100%;height:100%;box-sizing:border-box;padding:20px;background:#f1f2f0;color:#1c211e;position:relative;font-family:Arial,sans-serif;overflow:hidden;container-type:size}.sf-layered-menu header{height:42px;display:flex;align-items:center;gap:28px;border-bottom:1px solid #d4d9d5;position:relative;z-index:2}.sf-layered-menu nav{display:flex;gap:3px}.sf-layered-menu button{background:none;border:0;color:inherit;font:12px Arial;cursor:pointer}.sf-layered-menu nav button{height:44px;display:flex;align-items:center;gap:7px;padding:0 10px}.sf-layered-menu nav button[aria-expanded=true]{color:#131916}.lm-background{padding:45px 12px}.lm-background>span{font-size:10px;color:#727972}.lm-background>strong{display:block;font:normal 39px Georgia;letter-spacing:-.05em;margin-top:8px}.lm-panel{position:absolute;z-index:3;top:70px;left:20px;width:min(340px,calc(100% - 40px));background:#fff;border-radius:12px;border:1px solid #151a1420;box-shadow:0 12px 28px #17241918,0 2px 6px #00000009;overflow:hidden}.lm-track{display:flex;width:200%;transform:translateX(0);transition:transform .24s cubic-bezier(.22,1,.36,1)}.lm-track[data-deep=true]{transform:translateX(-50%)}.lm-page{width:50%;flex-shrink:0;box-sizing:border-box;padding:12px}.lm-page>small{display:block;font-size:10px;color:#6e766f;padding:4px 8px 8px}.lm-page>button{display:flex;align-items:center;gap:12px;width:100%;min-height:54px;padding:8px;border-radius:6px;text-align:left}.lm-page button:hover{background:#f3f5f1}.lm-page button:focus-visible,.sf-layered-menu nav button:focus-visible{outline:2px solid #304238;outline-offset:-2px}.lm-page button>span>small{display:block;font:10px/1.5 Arial;color:#7a807b;margin-top:4px}.lm-page button>b{font:15px Arial;margin-left:auto}.lm-page .lm-glyph{font:24px Arial;color:#6e7b71}.lm-page .lm-back{font-size:11px;min-height:35px;border-bottom:1px solid #eceeeb;border-radius:0;margin-bottom:4px;color:#68716a}.lm-page .lm-leaf{min-height:44px}.lm-leaf i{height:5px;width:5px;background:#c39828;border-radius:50%;margin-left:auto}.lm-leaf i+b{margin-left:0!important}.sf-layered-menu[data-still=true] .lm-track{transition:none}@media(prefers-reduced-motion:reduce){.sf-layered-menu .lm-track{transition:none}}@container(max-width:380px){.sf-layered-menu header{gap:17px}.sf-layered-menu{padding:16px}.lm-panel{left:16px;width:calc(100% - 32px)}}`}</style>
    </section>
  )
}

Where it works well

Navigate a small hierarchy inside one continuous disclosure surface.

Reduced motion

Hierarchy changes are immediate, preserving the same focus and back actions.

Things to know

A two-level local collection selector. It uses disclosure buttons rather than application-menu semantics.

More examples