## Expanding Search — Input
Icon to field with focus handled.
Docs: https://www.interior.dev/docs/expanding-search
Reference: https://www.interior.dev/reference/expanding-search
License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT)
### Install
Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project.
`bunx shadcn@latest add https://www.interior.dev/r/expanding-search.json`
Or run `bun add motion` and copy the source below.
### Usage
```tsx
"use client";
import { useState } from "react";
import { ExpandingSearch } from "@/components/interior/expanding-search";
export function LibraryToolbar({ items }: { items: string[] }) {
const [query, setQuery] = useState("");
const [searching, setSearching] = useState(false);
const hits = items.filter((name) =>
name.toLowerCase().includes(query.trim().toLowerCase()),
);
return (
Library
console.log("submit", value)}
/>
);
}
```
### Props
- `label` (`string`) — default: `"Search"`
Accessible name shared by the collapsed trigger and the input, so both read the same in the a11y tree.
- `placeholder` (`string`) — default: `"Search"`
Placeholder text; it is clipped by the shell while collapsed rather than being unmounted.
- `resultCount` (`number`)
When supplied, reserves a fixed tabular slot in the field and feeds the debounced live region. Omit it and the slot is never allocated.
- `align` (`"left" | "right"`) — default: `"right"`
Which edge of the reserved track the field is anchored to. Right-anchored fields open leftward over the toolbar actions.
- `value` (`string`)
Controlled query. Leave undefined to let the component own it.
- `defaultValue` (`string`) — default: `""`
Initial query when uncontrolled. A literal, so remounting resets the field.
- `onChange` (`(value: string) => void`)
Fires on every keystroke. Use it to mirror state, not to run the search.
- `onSearch` (`(value: string) => void`)
Fires once the typing settles, and immediately on Enter. This is the one to hang a query off.
- `onSubmit` (`(value: string) => void`)
Enter. The pending debounce is flushed first so onSearch never arrives after it.
- `debounce` (`number`) — default: `220`
Milliseconds of quiet before onSearch fires.
- `open` (`boolean`)
Controlled expansion. Focus handling still runs; only the state lives outside.
- `defaultOpen` (`boolean`) — default: `false`
Start expanded, for a page whose primary action is searching.
- `onOpenChange` (`(open: boolean) => void`)
Called once per real transition, never twice for the same expand.
- `collapseOnBlur` (`boolean`) — default: `true`
Collapse when focus leaves and the query is empty. A non-empty query is never collapsed away.
- `disabled` (`boolean`) — default: `false`
Blocks expansion and the input, and keeps the trigger out of the tab order's reach.
- `className` (`string`) — default: `""`
Appended last to the reserved track, so width and position are overridable from outside.
### Behavior notes
- The track reserves the expanded width before anything opens, so the row beside the field never reflows; neighbouring actions fade where they stand instead of being shoved sideways.
- Focus moves to the input synchronously inside the click handler rather than on animation-complete, so the iOS keyboard is not suppressed and a screen reader is never left pointing at a trigger that has already gone.
- Blur collapses the field only when it is empty, and a blur caused by switching browser tabs is ignored, so a typed query is never destroyed by clicking somewhere else.
- Escape clears a non-empty query and collapses an empty one, returning focus to the trigger instead of the document body, and the event is consumed so a dialog behind the field does not close along with it.
- Keystrokes are debounced before onSearch fires and Enter flushes the pending call, so the search runs once per intent; the polite live region announces only the settled result count, not one message per character.
- The input holds its expanded width at all times and is clipped by the shell, so the text inside never re-wraps mid-spring, and prefers-reduced-motion drops the transitions to zero without hiding either state.
### Source (`components/interior/expanding-search.tsx`)
```tsx
"use client";
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import { motion, useReducedMotion } from "motion/react";
const DISCLOSE = { type: "spring", stiffness: 380, damping: 38, mass: 0.7 } as const;
const CROSSFADE = { type: "spring", stiffness: 260, damping: 34, mass: 0.8 } as const;
const CELL = { type: "spring", stiffness: 520, damping: 34, mass: 0.45 } as const;
const INSTANT = { duration: 0 } as const;
const COLLAPSED = 40;
const TEXT_LEFT = 34;
const CLEAR_SLOT = 35;
const COUNT_SLOT = 38;
const ANNOUNCE_DELAY = 500;
const useIsomorphicLayoutEffect =
typeof window === "undefined" ? useEffect : useLayoutEffect;
export type UseExpandingSearchOptions = {
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
onSearch?: (value: string) => void;
onSubmit?: (value: string) => void;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
debounce?: number;
collapseOnBlur?: boolean;
disabled?: boolean;
};
export type UseExpandingSearchReturn = {
open: boolean;
focused: boolean;
query: string;
expand: () => void;
collapse: (returnFocus?: boolean) => void;
toggle: () => void;
clear: () => void;
inputRef: React.RefObject;
triggerRef: React.RefObject;
rootProps: {
onFocus: (event: React.FocusEvent) => void;
onBlur: (event: React.FocusEvent) => void;
};
triggerProps: {
ref: React.RefObject;
type: "button";
disabled: boolean;
tabIndex: number;
"aria-expanded": boolean;
onClick: () => void;
};
inputProps: {
ref: React.RefObject;
value: string;
disabled: boolean;
tabIndex: number;
onChange: (event: React.ChangeEvent) => void;
onKeyDown: (event: React.KeyboardEvent) => void;
onFocus: () => void;
};
};
export function useExpandingSearch({
value,
defaultValue = "",
onChange,
onSearch,
onSubmit,
open,
defaultOpen = false,
onOpenChange,
debounce = 220,
collapseOnBlur = true,
disabled = false,
}: UseExpandingSearchOptions = {}): UseExpandingSearchReturn {
const [ownValue, setOwnValue] = useState(defaultValue);
const [ownOpen, setOwnOpen] = useState(defaultOpen);
const [focused, setFocused] = useState(false);
const query = value ?? ownValue;
const isOpen = open ?? ownOpen;
const inputRef = useRef(null);
const triggerRef = useRef(null);
const timer = useRef | null>(null);
const openRef = useRef(isOpen);
const latest = useRef({ query, onChange, onSearch, onSubmit, onOpenChange });
latest.current = { query, onChange, onSearch, onSubmit, onOpenChange };
useEffect(() => {
openRef.current = isOpen;
}, [isOpen]);
useEffect(
() => () => {
if (timer.current) clearTimeout(timer.current);
},
[],
);
const setOpen = useCallback((next: boolean) => {
if (openRef.current === next) return;
openRef.current = next;
setOwnOpen(next);
latest.current.onOpenChange?.(next);
}, []);
const commit = useCallback(
(next: string) => {
setOwnValue(next);
latest.current.onChange?.(next);
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => {
timer.current = null;
latest.current.onSearch?.(next);
}, debounce);
},
[debounce],
);
const flush = useCallback(() => {
if (!timer.current) return;
clearTimeout(timer.current);
timer.current = null;
latest.current.onSearch?.(latest.current.query);
}, []);
const expand = useCallback(() => {
if (disabled) return;
setOpen(true);
inputRef.current?.focus();
}, [disabled, setOpen]);
const collapse = useCallback(
(returnFocus = false) => {
setOpen(false);
if (returnFocus) triggerRef.current?.focus();
},
[setOpen],
);
const toggle = useCallback(() => {
if (openRef.current) collapse(true);
else expand();
}, [collapse, expand]);
const clear = useCallback(() => {
commit("");
inputRef.current?.focus();
}, [commit]);
const onRootFocus = useCallback(() => setFocused(true), []);
const onRootBlur = useCallback(
(event: React.FocusEvent) => {
const next = event.relatedTarget as Node | null;
if (next && event.currentTarget.contains(next)) return;
setFocused(false);
if (!collapseOnBlur) return;
if (!document.hasFocus()) return;
if (latest.current.query.length > 0) return;
setOpen(false);
},
[collapseOnBlur, setOpen],
);
const onInputKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
if (latest.current.query.length > 0) {
commit("");
return;
}
collapse(true);
return;
}
if (event.key === "Enter") {
event.preventDefault();
flush();
latest.current.onSubmit?.(latest.current.query);
}
},
[collapse, commit, flush],
);
const onInputFocus = useCallback(() => setOpen(true), [setOpen]);
const onInputChange = useCallback(
(event: React.ChangeEvent) => commit(event.currentTarget.value),
[commit],
);
return {
open: isOpen,
focused,
query,
expand,
collapse,
toggle,
clear,
inputRef,
triggerRef,
rootProps: { onFocus: onRootFocus, onBlur: onRootBlur },
triggerProps: {
ref: triggerRef,
type: "button",
disabled,
tabIndex: isOpen ? -1 : 0,
"aria-expanded": isOpen,
onClick: expand,
},
inputProps: {
ref: inputRef,
value: query,
disabled,
tabIndex: isOpen ? 0 : -1,
onChange: onInputChange,
onKeyDown: onInputKeyDown,
onFocus: onInputFocus,
},
};
}
export type ExpandingSearchProps = UseExpandingSearchOptions & {
label?: string;
placeholder?: string;
resultCount?: number;
align?: "left" | "right";
className?: string;
};
export function ExpandingSearch({
label = "Search",
placeholder = "Search",
resultCount,
align = "right",
className = "",
...options
}: ExpandingSearchProps) {
const reduced = useReducedMotion();
const auto = useId();
const inputId = `${auto}-field`;
const {
open,
focused,
query,
clear,
inputRef,
rootProps,
triggerProps,
inputProps,
} = useExpandingSearch(options);
const trackRef = useRef(null);
const [track, setTrack] = useState(0);
useIsomorphicLayoutEffect(() => {
const el = trackRef.current;
if (!el) return;
const read = (w: number) =>
setTrack((prev) => (Math.abs(prev - w) < 0.5 ? prev : w));
read(el.getBoundingClientRect().width);
const observer = new ResizeObserver((entries) => {
const box = entries[0];
if (box) read(box.contentRect.width);
});
observer.observe(el);
return () => observer.disconnect();
}, []);
const [announced, setAnnounced] = useState("");
useEffect(() => {
const id = setTimeout(() => {
if (!open || query.length === 0 || resultCount === undefined) {
setAnnounced("");
return;
}
setAnnounced(
`${resultCount} ${resultCount === 1 ? "result" : "results"} for ${query}`,
);
}, ANNOUNCE_DELAY);
return () => clearTimeout(id);
}, [open, query, resultCount]);
const expanded = Math.max(COLLAPSED, track);
const rightInset = CLEAR_SLOT + (resultCount === undefined ? 0 : COUNT_SLOT);
const inner = Math.max(0, expanded - TEXT_LEFT - rightInset);
const filled = query.length > 0;
const shellMotion = reduced ? INSTANT : DISCLOSE;
const fadeMotion = reduced ? INSTANT : CROSSFADE;
const cellMotion = reduced ? INSTANT : CELL;
return (
{
if (event.target !== event.currentTarget) return;
event.preventDefault();
if (open) inputRef.current?.focus();
}}
className={`absolute inset-y-0 ${
align === "right" ? "right-0" : "left-0"
} overflow-hidden rounded-[10px] border-2 transition-[background-color,border-color,box-shadow] duration-150 ${
focused
? "border-[#4568FF] bg-white dark:border-[#93B0FF] dark:bg-[#252522]"
: "border-stone-200 bg-stone-100/70 shadow-[inset_0_1px_2px_rgba(28,25,23,0.07)] dark:border-white/[0.08] dark:bg-[#1D1D1A] dark:shadow-[inset_0_1px_2px_rgba(0,0,0,0.45)]"
}`}
>
{resultCount === undefined ? null : (
{filled ? resultCount : ""}
)}
{announced}
);
}
```