Subframe
Components & interactionsCollapsible breadcrumbs

Breadcrumb expansion

Hidden ancestors unfold inline, keeping the current document anchored in a readable path.

Expand the ellipsis, choose an ancestor, and open its child to return. Escape collapses the trail.

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 * as React from "react"
const PATH = ["Studio", "Editions", "Kinetic", "Cover.svg"]
function Folder() {
  return (
    <svg viewBox="0 0 24 20" aria-hidden>
      <path d="M2 4h7l2 2h11v12H2z" />
      <path d="M2 5V2h7l2 2h9v2" />
    </svg>
  )
}
function ArtworkFile() {
  return (
    <svg className="artwork-file" viewBox="0 0 24 28" aria-hidden>
      <path d="M4 2h10l6 6v18H4z" />
      <path d="M14 2v6h6M8 13l8 6m-8-1 4 3" fill="none" />
    </svg>
  )
}
export default function Example({ active = true }: { active?: boolean }) {
  const [expanded, setExpanded] = React.useState(false),
    [depth, setDepth] = React.useState(3),
    [keyboard, setKeyboard] = React.useState(false)
  const trigger = React.useRef<HTMLButtonElement>(null),
    middle = React.useRef<HTMLDivElement>(null)
  const visible = expanded || depth < 3
  const [parentWidth, setParentWidth] = React.useState(132)
  const returnFocus = React.useRef(false)
  const enterParents = React.useRef(false)
  React.useLayoutEffect(() => {
    if (middle.current) setParentWidth(middle.current.scrollWidth)
    if (expanded && enterParents.current)
      middle.current?.querySelector<HTMLButtonElement>("button")?.focus({ preventScroll: true })
    enterParents.current = false
    if (!expanded && returnFocus.current) {
      trigger.current?.focus({ preventScroll: true })
      returnFocus.current = false
    }
  }, [depth, expanded])
  const navigate = (next: number) => {
    setDepth(next)
    setExpanded(false)
  }
  const collapse = () => {
    returnFocus.current = true
    setExpanded(false)
  }
  return (
    <section
      className="sf-breadcrumb-collapse"
      data-still={!active || keyboard}
      onPointerDownCapture={() => setKeyboard(false)}
      onKeyDownCapture={(e) => {
        setKeyboard(true)
        if (e.key === "Escape") collapse()
      }}
      aria-label="Expandable project path"
    >
      <div className="path-card">
        <nav aria-label="Document location">
          <ol>
            <li>
              {depth === 0 ? (
                <span className="current" aria-current="page">
                  <Folder />
                  Studio
                </span>
              ) : (
                <button type="button" disabled={!active} onClick={() => navigate(0)}>
                  <Folder />
                  Studio
                </button>
              )}
            </li>
            {depth > 0 && (
              <>
                <li className="separator" aria-hidden>
                  /
                </li>
                {depth > 1 && (
                  <>
                    <li className="parent-wrap" style={{ width: visible ? parentWidth : 42 }}>
                      {depth === 3 && (
                        <button
                          type="button"
                          className="ellipsis"
                          ref={trigger}
                          disabled={!active || expanded}
                          aria-label="Expand parent folders"
                          aria-expanded={expanded}
                          aria-hidden={expanded}
                          tabIndex={expanded ? -1 : undefined}
                          data-visible={!expanded}
                          onClick={(event) => {
                            enterParents.current = event.detail === 0
                            setExpanded(true)
                          }}
                        >
                          <span>···</span>
                        </button>
                      )}
                      <div ref={middle} className="parents" inert={!visible} data-visible={visible}>
                        {PATH.slice(1, depth).map((name, i) => (
                          <React.Fragment key={name}>
                            {i > 0 && (
                              <span className="separator" aria-hidden>
                                /
                              </span>
                            )}
                            <button type="button" disabled={!active} onClick={() => navigate(i + 1)}>
                              {name}
                            </button>
                          </React.Fragment>
                        ))}
                      </div>
                    </li>
                    <li className="separator" aria-hidden>
                      /
                    </li>
                  </>
                )}
                <li className="current" aria-current="page">
                  {PATH[depth]}
                </li>
              </>
            )}
          </ol>
          {depth === 3 && expanded && (
            <button
              type="button"
              className="collapse"
              disabled={!active}
              aria-label="Collapse parent folders"
              aria-expanded={expanded}
              onClick={collapse}
            >
              <svg viewBox="0 0 16 16" aria-hidden>
                <path d="m6 4-4 4 4 4m4-8 4 4-4 4" />
              </svg>
            </button>
          )}
        </nav>
        <div className="content" key={depth}>
          {depth === 3 ? (
            <div className="file-detail">
              <div className="cover" aria-hidden>
                <svg viewBox="0 0 92 122">
                  <rect width="92" height="122" fill="#da4c2b" />
                  {Array.from({ length: 6 }, (_, i) => (
                    <path key={i} d={`M${-13 + i * 13} 13 108 ${91 + i * 4}`} stroke="#f4e4c6" strokeWidth="5" />
                  ))}
                  <path d="M11 102h33" stroke="#351f19" strokeWidth="3" />
                </svg>
              </div>
              <div>
                <strong>Cover.svg</strong>
                <span>1600 × 2400</span>
                <small>Vector artwork</small>
              </div>
              <span className="saved">
                <i />
                Saved
              </span>
            </div>
          ) : (
            <button type="button" className="folder-row" disabled={!active} onClick={() => navigate(depth + 1)}>
              <span className="folder-art" aria-hidden>
                {depth === 2 ? <ArtworkFile /> : <Folder />}
              </span>
              <span>
                <strong>{PATH[depth + 1]}</strong>
                <small>{depth === 2 ? "Vector artwork" : "1 item"}</small>
              </span>
              <svg className="next" viewBox="0 0 16 16" aria-hidden>
                <path d="m6 3 5 5-5 5" />
              </svg>
            </button>
          )}
        </div>
      </div>
      <div className="sr" role="status">
        Current location: {PATH.slice(0, depth + 1).join(", ")}
      </div>
      <style>{`
.sf-breadcrumb-collapse{width:100%;height:100%;display:grid;place-items:center;background:#d8e0e6;color:#354958;font:12px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;overflow:hidden;-webkit-font-smoothing:antialiased}.sf-breadcrumb-collapse *{box-sizing:border-box}.sf-breadcrumb-collapse button{font:inherit;color:inherit;cursor:pointer;touch-action:manipulation;border:0;background:none}.sf-breadcrumb-collapse button:disabled{cursor:default}.sf-breadcrumb-collapse :focus-visible{outline:2px solid #4b7b9b;outline-offset:2px}.sf-breadcrumb-collapse .path-card{width:528px;overflow:hidden;background:#fcfdfd;border-radius:12px;border:1px solid #b9c8d2;box-shadow:0 18px 35px -18px #29445666,0 2px 4px #344e6010}.sf-breadcrumb-collapse nav{height:76px;display:flex;align-items:center;padding:0 21px;border-bottom:1px solid #dce4e9;background:linear-gradient(#fff,#f4f7f9)}.sf-breadcrumb-collapse ol{display:flex;align-items:center;gap:0;padding:0;margin:0;list-style:none;min-width:0}.sf-breadcrumb-collapse li{display:flex;align-items:center;flex:none}.sf-breadcrumb-collapse nav button{height:44px;display:flex;align-items:center;gap:7px;padding:0 5px;border-radius:6px;font-size:14px;white-space:nowrap;letter-spacing:-.25px}.sf-breadcrumb-collapse nav button:hover{background:#eaf0f4}.sf-breadcrumb-collapse nav svg{width:17px;height:16px;fill:#bdcdd8;stroke:#849cab;stroke-width:1;stroke-linejoin:round}.sf-breadcrumb-collapse .separator{font-size:16px;font-weight:300;color:#b1c0ca;padding:0 10px}.sf-breadcrumb-collapse .current{font-size:14px;font-weight:550;letter-spacing:-.3px;color:#2d4b60;white-space:nowrap;display:flex;align-items:center;gap:7px}.sf-breadcrumb-collapse .ellipsis{position:absolute;top:0;left:0;opacity:0;pointer-events:none;transition:opacity 120ms;width:42px;justify-content:center;padding:0;background:#e3ebf0;box-shadow:inset 0 1px 2px #3b5a7012;border:1px solid #d6e0e7}.sf-breadcrumb-collapse .ellipsis[data-visible=true]{opacity:1;pointer-events:auto}.sf-breadcrumb-collapse .ellipsis span{font-size:23px;line-height:1;margin-top:-8px;color:#638399;letter-spacing:1px}.sf-breadcrumb-collapse .parent-wrap{position:relative;height:44px;overflow:hidden;transition:width 280ms cubic-bezier(.22,1,.36,1)}.sf-breadcrumb-collapse .parents{position:absolute;left:0;top:0;display:flex;align-items:center;width:max-content;opacity:0;transform:translateX(-8px);transition:opacity 170ms ease,transform 280ms cubic-bezier(.22,1,.36,1)}.sf-breadcrumb-collapse .parents[data-visible=true]{opacity:1;transform:none}.sf-breadcrumb-collapse .parents button{font-size:13px}.sf-breadcrumb-collapse .parents .separator{padding:0 8px}.sf-breadcrumb-collapse .collapse{margin-left:auto;width:28px;padding:0!important;justify-content:center}.sf-breadcrumb-collapse .collapse svg{width:14px;fill:none;stroke:#9aacb8;stroke-width:1.3}.sf-breadcrumb-collapse .content{height:164px;padding:22px 26px;animation:sf-path-content 220ms cubic-bezier(.22,1,.36,1)}.sf-breadcrumb-collapse .file-detail{height:100%;display:flex;align-items:center;gap:21px}.sf-breadcrumb-collapse .cover{width:77px;height:103px;flex:none;transform:rotate(-3deg);box-shadow:0 2px 3px #273c4926,0 9px 12px -5px #273c4936;border:3px solid #fff;outline:1px solid #dbe0df}.sf-breadcrumb-collapse .cover svg{width:100%;height:100%;display:block}.sf-breadcrumb-collapse .file-detail strong{display:block;font-size:13px;font-weight:500;color:#465b69}.sf-breadcrumb-collapse .file-detail div>span{display:block;margin-top:5px;font-size:11px;color:#8b9ca8}.sf-breadcrumb-collapse .file-detail small{display:block;font-size:10px;color:#a4b0b7;margin-top:3px}.sf-breadcrumb-collapse .saved{margin-left:auto;align-self:flex-start;display:flex;align-items:center;gap:5px;color:#91a09b;font-size:9px;padding-top:5px}.sf-breadcrumb-collapse .saved i{width:4px;height:4px;border-radius:50%;background:#78a894}.sf-breadcrumb-collapse .folder-row{height:100%;display:flex;align-items:center;width:100%;gap:19px;text-align:left;border-radius:7px;padding:0 15px}.sf-breadcrumb-collapse .folder-row:hover{background:#f1f5f7}.sf-breadcrumb-collapse .folder-art{width:65px;height:54px}.sf-breadcrumb-collapse .folder-art svg{width:100%;height:100%;fill:#aac3d1;stroke:#7e9cad;stroke-width:.5}.sf-breadcrumb-collapse .folder-row strong{display:block;font-size:15px;font-weight:500}.sf-breadcrumb-collapse .folder-row small{display:block;font-size:10px;margin-top:5px;color:#95a5af}.sf-breadcrumb-collapse .next{margin-left:auto;width:15px;fill:none;stroke:#9daeba;stroke-width:1}.sf-breadcrumb-collapse footer{display:flex;justify-content:space-between;align-items:center;border-top:1px solid #e5ebee;height:36px;padding:0 23px;font-size:9px;color:#a3afb8;background:#f9fbfc}.sf-breadcrumb-collapse .sr{position:absolute;width:1px;height:1px;clip-path:inset(50%);overflow:hidden}.sf-breadcrumb-collapse[data-still=true] *{transition:none!important;animation:none!important}@media(prefers-reduced-motion:reduce){.sf-breadcrumb-collapse *{transition:none!important;animation:none!important}}@keyframes sf-path-content{from{opacity:.4;transform:translateX(5px)}to{opacity:1;transform:none}}`}</style>
    </section>
  )
}

Where it works well

Reveal and navigate a compact project hierarchy without a detached parent menu.

Reduced motion

Finite transitions become immediate under reduced motion and keyboard input. Host pause disables controls; any owned drag is cancelled.

Things to know

Four local hierarchy levels with one child each; no filesystem or router integration. The original SVG cover is illustrative content.

More examples