Subframe
Components & interactionsExpanding search combobox

Archive search

A compact archive tab expands into a searchable document picker and retains your selection.

Open search, filter documents and select a result.

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, useRef, useState } from "react"

const documents = [
  { title: "Ways of seeing", type: "Essay", pages: 24, color: "#e7522d", mark: "circle" },
  { title: "A house in the hills", type: "Architecture", pages: 48, color: "#375b4a", mark: "arch" },
  { title: "The shape of sound", type: "Research", pages: 16, color: "#253e94", mark: "wave" },
  { title: "Collected fragments", type: "Journal", pages: 32, color: "#ddbb56", mark: "grid" },
]

export default function ExpandingSearch({ active = true }: { active?: boolean }) {
  const [open, setOpen] = useState(false)
  const [query, setQuery] = useState("")
  const [selected, setSelected] = useState<number | null>(null)
  const [highlighted, setHighlighted] = useState(-1)
  const [instant, setInstant] = useState(false)
  const input = useRef<HTMLInputElement>(null)
  const trigger = useRef<HTMLButtonElement>(null)
  const resultsId = useId()
  const results = documents
    .map((item, index) => ({ ...item, index }))
    .filter((item) => `${item.title} ${item.type}`.toLowerCase().includes(query.trim().toLowerCase()))
  useEffect(() => {
    if (open) input.current?.focus()
  }, [open])
  const close = (restoreFocus = true) => {
    setOpen(false)
    setQuery("")
    setHighlighted(-1)
    if (restoreFocus) trigger.current?.focus()
  }
  const choose = (index: number) => {
    setSelected(index)
    close()
  }
  return (
    <div className="sf-index-search" data-open={open} data-instant={instant} data-active={active}>
      <style>{`
.sf-index-search{position:relative;height:100%;min-height:300px;display:grid;place-items:center;overflow:hidden;background:#e5e2db;color:#242824;font-family:Arial,sans-serif;-webkit-font-smoothing:antialiased}
.sf-index-search *{box-sizing:border-box}.sf-index-search button,.sf-index-search input{font:inherit;-webkit-tap-highlight-color:transparent}
.sf-index-drawer{position:relative;width:382px;height:277px;transform:translate(65px,104px);transition:transform 320ms cubic-bezier(.22,1,.36,1)}
.sf-index-search[data-open=true] .sf-index-drawer{transform:translate(0,0)}
.sf-index-paper{position:absolute;inset:0;border-radius:2px;background:#faf9f4;border:1px solid #cfcec6;clip-path:inset(0 130px 218px 0);transition:clip-path 320ms cubic-bezier(.22,1,.36,1);pointer-events:none}
.sf-index-search[data-open=true] .sf-index-paper{clip-path:inset(0)}
.sf-index-head{height:59px;position:relative;display:flex;align-items:center}
.sf-index-tab{position:absolute;z-index:2;left:0;top:0;width:47px;height:59px;border:0;border-radius:2px 0 0 2px;background:#d74129;color:#fff9ec;padding:0;cursor:pointer;box-shadow:inset 0 1px #ffbea233,inset 0 -1px #92312044;touch-action:manipulation}
.sf-index-tab:before{content:"";position:absolute;left:8px;right:8px;top:13px;height:1px;background:#ffdac34d}.sf-index-tab:after{content:"";position:absolute;left:8px;right:8px;bottom:13px;height:1px;background:#a8281944}
.sf-index-tab svg{position:absolute;left:14px;top:20px;width:19px;height:19px;transition:opacity 100ms ease}.sf-index-tab .sf-index-close{opacity:0}.sf-index-search[data-open=true] .sf-index-tab .sf-index-close{opacity:1}.sf-index-search[data-open=true] .sf-index-tab .sf-index-find{opacity:0}
.sf-index-tab:active{background:#bb321e}
.sf-index-collapsed{position:absolute;left:47px;top:0;display:flex;align-items:center;justify-content:space-between;gap:14px;width:205px;height:59px;border:0;background:transparent;padding:0 17px 0 15px;cursor:pointer;text-align:left;color:#272e29;transition:opacity 100ms ease;white-space:nowrap;overflow:hidden}
.sf-index-collapsed span{font-size:14px;letter-spacing:-.35px;overflow:hidden;text-overflow:ellipsis}
.sf-index-search[data-open=true] .sf-index-collapsed{opacity:0;pointer-events:none}
.sf-index-field{position:absolute;left:63px;right:17px;top:0;height:59px;display:flex;align-items:center;gap:12px;opacity:0;pointer-events:none;transition:opacity 140ms ease}
.sf-index-search[data-open=true] .sf-index-field{opacity:1;pointer-events:auto}
.sf-index-field input{min-width:0;flex:1;width:100%;padding:0;border:0;outline:0;background:none;color:#28332a;font-size:16px;line-height:24px;caret-color:#d74129;letter-spacing:-.3px}.sf-index-field input::placeholder{color:#818578}
.sf-index-field:focus-within:after{content:"";position:absolute;bottom:10px;left:0;right:0;height:1px;background:#c8cbbc}
.sf-index-field kbd{font:8px ui-monospace,monospace;color:#8b9084;border:1px solid #d9dbd1;border-bottom-width:2px;padding:3px;border-radius:3px}
.sf-index-results{position:relative;padding:0 17px;opacity:0;transform:translateY(-9px);transition:opacity 150ms ease,transform 250ms cubic-bezier(.22,1,.36,1)}
.sf-index-search[data-open=true] .sf-index-results{opacity:1;transform:none}
.sf-index-rule{height:1px;background:#1e2923;margin-bottom:7px}
.sf-index-result{position:relative;display:flex;align-items:center;gap:11px;width:100%;height:45px;margin:0;border:0;border-bottom:1px solid #dedfd5;background:none;text-align:left;padding:5px 0;color:#2d352c;cursor:pointer}
.sf-index-cover{position:relative;width:23px;height:32px;flex-shrink:0;display:grid;place-items:center;overflow:hidden;border:1px solid #0002;box-shadow:inset 2px 0 #00000017,1px 1px 0 #b7b9af88}.sf-index-cover svg{width:17px;height:24px;color:#fcf9e9}
.sf-index-result-title{flex:1;min-width:0;display:flex;flex-direction:column;gap:3px;font-size:12px;letter-spacing:-.15px}.sf-index-result-title small{font:8px ui-monospace,SFMono-Regular,Consolas,monospace;color:#808679;letter-spacing:0}
.sf-index-accession{font:9px ui-monospace,SFMono-Regular,Consolas,monospace;color:#858a7e;display:flex;gap:9px;align-items:center}.sf-index-accession svg{width:12px;height:12px;opacity:0;transform:translateX(-3px);transition:transform 120ms ease-out,opacity 120ms ease}
.sf-index-result[aria-selected=true]{background:#eaece3;color:#263c2f}.sf-index-result[aria-selected=true] .sf-index-accession svg{opacity:1;transform:none}
.sf-index-empty{height:180px;margin:0;display:grid;place-items:center;font-size:12px;color:#717a69}
.sf-index-footer{display:flex;justify-content:space-between;margin-top:10px;font:8px ui-monospace,SFMono-Regular,Consolas,monospace;letter-spacing:.3px;color:#7e8678}
.sf-index-search button:focus-visible{outline:2px solid #213f32;outline-offset:3px}.sf-index-tab:focus-visible{outline-offset:3px}
.sf-index-status{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}
@media(hover:hover)and(pointer:fine){.sf-index-result:hover{background:#eeeee5}.sf-index-result:hover .sf-index-accession svg{opacity:1;transform:none}.sf-index-collapsed:hover{color:#b23720}}
.sf-index-search[data-instant=true] *,.sf-index-search[data-active=false] *{transition:none}
@media(prefers-reduced-motion:reduce){.sf-index-search *{transition:none}}
@media(max-width:410px){.sf-index-search{transform-origin:center}.sf-index-drawer{width:350px}.sf-index-search:not([data-open=true]) .sf-index-drawer{transform:translate(49px,104px)}.sf-index-paper{clip-path:inset(0 98px 218px 0)}}
`}</style>
      <div
        className="sf-index-drawer"
        onBlur={(event) => {
          if (open && !event.currentTarget.contains(event.relatedTarget as Node | null)) close(false)
        }}
        onKeyDown={(event) => {
          if (event.key === "Escape" && open) {
            event.preventDefault()
            setInstant(true)
            close()
          }
        }}
      >
        <div className="sf-index-paper" aria-hidden="true" />
        <div className="sf-index-head">
          <button
            type="button"
            ref={trigger}
            className="sf-index-tab"
            aria-label={open ? "Close archive search" : "Search archive"}
            aria-expanded={open}
            aria-controls={resultsId}
            onClick={(event) => {
              setInstant(event.detail === 0)
              if (open) close()
              else setOpen(true)
            }}
          >
            <svg
              className="sf-index-find"
              aria-hidden="true"
              viewBox="0 0 20 20"
              fill="none"
              stroke="currentColor"
              strokeWidth="1.4"
            >
              <circle cx="8" cy="8" r="4.5" />
              <path d="m11.5 11.5 5 5" />
            </svg>
            <svg
              className="sf-index-close"
              aria-hidden="true"
              viewBox="0 0 20 20"
              fill="none"
              stroke="currentColor"
              strokeWidth="1.4"
            >
              <path d="m5 5 10 10M15 5 5 15" />
            </svg>
          </button>
          <button
            type="button"
            className="sf-index-collapsed"
            tabIndex={-1}
            inert={open}
            aria-label="Search archive"
            onClick={(event) => {
              setInstant(event.detail === 0)
              setOpen(true)
            }}
          >
            <span>{selected === null ? "Find in the archive" : documents[selected].title}</span>
          </button>
          <div className="sf-index-field" inert={!open}>
            <input
              ref={input}
              role="combobox"
              aria-autocomplete="list"
              aria-expanded={open}
              aria-label="Search archive titles"
              aria-controls={resultsId}
              aria-activedescendant={
                highlighted >= 0 && results[highlighted] ? `${resultsId}-${results[highlighted].index}` : undefined
              }
              placeholder="Search the archive"
              value={query}
              onChange={(event) => {
                setQuery(event.target.value)
                setHighlighted(-1)
              }}
              onKeyDown={(event) => {
                if (event.nativeEvent.isComposing) return
                if (event.key === "ArrowDown" || event.key === "ArrowUp") {
                  event.preventDefault()
                  setInstant(true)
                  if (results.length)
                    setHighlighted((index) =>
                      index < 0
                        ? event.key === "ArrowDown"
                          ? 0
                          : results.length - 1
                        : (index + (event.key === "ArrowDown" ? 1 : -1) + results.length) % results.length,
                    )
                }
                if (event.key === "Enter" && results.length) {
                  event.preventDefault()
                  setInstant(true)
                  choose(results[Math.max(0, highlighted)].index)
                }
              }}
            />
            <kbd aria-hidden="true">ESC</kbd>
          </div>
        </div>
        <div className="sf-index-results" inert={!open}>
          <div className="sf-index-rule" />
          <div id={resultsId} role="listbox" aria-label="Archive documents">
            {results.map((item, index) => (
              <button
                type="button"
                key={item.title}
                id={`${resultsId}-${item.index}`}
                role="option"
                aria-selected={index === highlighted}
                tabIndex={-1}
                className="sf-index-result"
                onClick={(event) => {
                  setInstant(event.detail === 0)
                  choose(item.index)
                }}
                onPointerMove={() => setHighlighted(index)}
              >
                <span className="sf-index-cover" style={{ background: item.color }} aria-hidden="true">
                  <svg viewBox="0 0 24 32" fill="none" stroke="currentColor" strokeWidth="1.1">
                    {item.mark === "circle" && (
                      <>
                        <circle cx="12" cy="13" r="7" />
                        <path d="M5 24H19" />
                      </>
                    )}
                    {item.mark === "arch" && <path d="M5 27V12a7 7 0 0 1 14 0v15M8 27V13a4 4 0 0 1 8 0v14" />}
                    {item.mark === "wave" && (
                      <path d="M3 11Q8 2 12 11T21 11M3 17Q8 8 12 17T21 17M3 23Q8 14 12 23T21 23" />
                    )}
                    {item.mark === "grid" && <path d="M4 7H20V25H4ZM4 13H20M4 19H20M10 7V25M15 7V25" />}
                  </svg>
                </span>
                <span className="sf-index-result-title">
                  {item.title}
                  <small>
                    {item.type} · {item.pages} pages
                  </small>
                </span>
                <span className="sf-index-accession">
                  <span>00{item.index + 1}</span>
                  <svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor">
                    <path d="M2 8H13M8 3l5 5-5 5" />
                  </svg>
                </span>
              </button>
            ))}
            {!results.length && (
              <p className="sf-index-empty" role="status">
                No matching titles
              </p>
            )}
          </div>
          <div className="sf-index-footer">
            <span>{String(results.length).padStart(2, "0")} TITLES</span>
            <span>↑↓ MOVE · ↵ SELECT</span>
          </div>
        </div>
        <span className="sf-index-status" role="status">
          {selected !== null ? `Selected ${documents[selected].title}` : ""}
        </span>
      </div>
    </div>
  )
}

Where it works well

Fit local document search and selection into a compact toolbar.

Reduced motion

Keyboard, reduced-motion and paused-host transitions are immediate.

Things to know

Local sample data only; no remote search or document navigation. Selecting changes the compact title; it does not open a full document.

More examples