A dark floating bar at the bottom of a notes window. At rest it holds three icon tools and a search button. Pressing search springs the bar wider while the icons blur out and a back arrow, a text field and a result count blur in. Typing filters the list above in real time, with rows sliding to close the gaps. Escape or the back arrow returns the tools. The demo opens the search and types a word on its own once. On phones the bar stops at the width of the window so it never overflows.
// Adapted from Motion Primitives (https://motion-primitives.com/docs/toolbar-dynamic)
// Copyright (c) ibelick. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "framer-motion";
const notes = [
{ id: 1, title: "Invoice run for March", body: "Send the eleven open invoices before Friday noon.", tag: "Finance", tone: "bg-violet-100 text-violet-900" },
{ id: 2, title: "Studio rota", body: "Priya covers Tuesday, Marek swaps to the late shift.", tag: "Team", tone: "bg-amber-100 text-amber-900" },
{ id: 3, title: "Supplier call notes", body: "New paper stock arrives on the 14th. Ask about invoice terms.", tag: "Suppliers", tone: "bg-emerald-100 text-emerald-900" },
{ id: 4, title: "Window display ideas", body: "Hanging prints, one colour per week, start with blue.", tag: "Shop", tone: "bg-sky-100 text-sky-900" },
{ id: 5, title: "Late invoice reminder", body: "Northgate Framing is 20 days over. Call, do not email.", tag: "Finance", tone: "bg-violet-100 text-violet-900" },
];
const tools = [
{ id: "new", label: "New note", path: "M10 4v12M4 10h12" },
{ id: "pin", label: "Pin selected note", path: "m8 3 6 0-1 6 3 3H4l3-3-1-6h2m2 9v5" },
{ id: "archive", label: "Archive selected note", path: "M3 5h14v3H3zm1.5 3v8h11V8M8 11.5h4" },
];
// Width of the bar in each state, in pixels. It never grows past its container.
const CLOSED_WIDTH = 208;
const OPEN_WIDTH = 336;
const DEMO_QUERY = "invoice";
const SPRING = { type: "spring" as const, bounce: 0.12, duration: 0.5 };
const SWAP = {
initial: { opacity: 0, filter: "blur(4px)", scale: 0.94 },
animate: { opacity: 1, filter: "blur(0px)", scale: 1 },
exit: { opacity: 0, filter: "blur(4px)", scale: 0.94 },
};
export default function FloatingToolbarThatExpandsIntoASearchField() {
const reduceMotion = useReducedMotion();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [pressed, setPressed] = useState<string | null>(null);
const touched = useRef(false);
const byUser = useRef(false);
const inputRef = useRef<HTMLInputElement>(null);
const searchRef = useRef<HTMLButtonElement>(null);
const barRef = useRef<HTMLDivElement>(null);
// Demo: open the search and type a word once.
useEffect(() => {
if (reduceMotion) {
setOpen(true);
setQuery(DEMO_QUERY);
return;
}
const timers: ReturnType<typeof setTimeout>[] = [];
timers.push(
setTimeout(() => {
if (touched.current) return;
setOpen(true);
DEMO_QUERY.split("").forEach((_, index) => {
timers.push(
setTimeout(() => {
if (!touched.current) setQuery(DEMO_QUERY.slice(0, index + 1));
}, 520 + index * 110)
);
});
}, 650)
);
return () => timers.forEach(clearTimeout);
}, [reduceMotion]);
useEffect(() => {
if (!open) return;
if (byUser.current) inputRef.current?.focus();
const onDown = (event: PointerEvent) => {
if (barRef.current && !barRef.current.contains(event.target as Node) && !inputRef.current?.value) {
touched.current = true;
setOpen(false);
}
};
document.addEventListener("pointerdown", onDown);
return () => document.removeEventListener("pointerdown", onDown);
}, [open]);
const user = () => {
touched.current = true;
byUser.current = true;
};
const close = () => {
user();
setOpen(false);
setQuery("");
requestAnimationFrame(() => searchRef.current?.focus());
};
const term = query.trim().toLowerCase();
const matches = notes.filter((note) => !term || `${note.title} ${note.body} ${note.tag}`.toLowerCase().includes(term));
return (
<MotionConfig transition={SPRING} reducedMotion="user">
<section className="bg-violet-50 px-5 py-14 text-slate-900 sm:px-8 sm:py-16">
<div className="mx-auto max-w-2xl">
<div className="text-center">
<p className="text-xs font-semibold uppercase tracking-widest text-violet-700">Papercut Print Shop, back office</p>
<h2 className="mt-3 text-3xl font-bold tracking-tight sm:text-4xl">One bar. Tools until you search.</h2>
</div>
{/* App window. The toolbar floats over the bottom of the list. */}
<div className="relative mt-8 h-[30rem] overflow-hidden rounded-3xl bg-white shadow-xl shadow-violet-900/10 ring-1 ring-slate-900/5">
<div className="flex items-center justify-between border-b border-slate-200 px-5 py-4">
<h3 className="text-sm font-semibold">Notes</h3>
<p className="text-xs text-slate-600" aria-live="polite">
{term ? `${matches.length} of ${notes.length} match "${query.trim()}"` : `${notes.length} notes`}
</p>
</div>
<ul className="space-y-2 p-3 pb-24">
<AnimatePresence initial={false} mode="popLayout">
{matches.map((note) => (
<motion.li
key={note.id}
layout
initial={{ opacity: 0, scale: 0.97 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.97 }}
className="flex items-start gap-3 rounded-2xl border border-slate-200 bg-white p-3.5"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{note.title}</p>
<p className="mt-0.5 truncate text-sm text-slate-600">{note.body}</p>
</div>
<span className={`shrink-0 rounded-full px-2.5 py-1 text-xs font-semibold ${note.tone}`}>{note.tag}</span>
</motion.li>
))}
</AnimatePresence>
{matches.length === 0 && <li className="px-3 py-8 text-center text-sm text-slate-600">No notes match that search.</li>}
</ul>
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 bottom-0 h-28 bg-gradient-to-t from-white via-white/90 to-transparent" />
<div className="absolute inset-x-3 bottom-4 flex justify-center">
<motion.div
ref={barRef}
role={open ? "search" : "toolbar"}
aria-label={open ? "Search notes" : "Note tools"}
initial={false}
animate={{ width: open ? OPEN_WIDTH : CLOSED_WIDTH }}
style={{ maxWidth: "100%" }}
className="h-14 overflow-hidden rounded-2xl bg-slate-950 p-2 text-white shadow-2xl shadow-violet-950/40"
>
<AnimatePresence initial={false} mode="popLayout">
{open ? (
<motion.div key="search" {...SWAP} className="flex h-10 items-center gap-1" style={{ width: OPEN_WIDTH - 16, maxWidth: "100%" }}>
<button
type="button"
onClick={close}
aria-label="Close search"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl text-slate-200 hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-300"
>
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="h-4 w-4">
<path d="M16 10H4m0 0 4.5-4.5M4 10l4.5 4.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<label htmlFor="expanding-toolbar-search" className="sr-only">
Search notes
</label>
<input
id="expanding-toolbar-search"
ref={inputRef}
type="search"
value={query}
placeholder="Search notes"
onFocus={() => {
touched.current = true;
}}
onChange={(event) => {
user();
setQuery(event.target.value);
}}
onKeyDown={(event) => {
if (event.key === "Escape") close();
}}
className="h-10 min-w-0 flex-1 rounded-xl bg-white/10 px-3 text-sm text-white placeholder:text-slate-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-300"
/>
<span className="w-9 shrink-0 text-center font-mono text-xs text-slate-300">{matches.length}</span>
</motion.div>
) : (
<motion.div key="tools" {...SWAP} className="flex h-10 items-center gap-1" style={{ width: CLOSED_WIDTH - 16 }}>
{tools.map((tool) => (
<button
key={tool.id}
type="button"
aria-label={tool.label}
aria-pressed={pressed === tool.id}
onClick={() => {
user();
setPressed(pressed === tool.id ? null : tool.id);
}}
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-xl focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-300 ${
pressed === tool.id ? "bg-violet-500 text-white" : "text-slate-200 hover:bg-white/10"
}`}
>
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="h-5 w-5">
<path d={tool.path} stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
))}
<span aria-hidden="true" className="mx-1 h-5 w-px bg-white/20" />
<button
ref={searchRef}
type="button"
aria-label="Search notes"
aria-expanded={false}
onClick={() => {
user();
setOpen(true);
}}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl text-slate-200 hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-300"
>
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="h-5 w-5">
<circle cx="9" cy="9" r="5" stroke="currentColor" strokeWidth="1.7" />
<path d="m13 13 3.5 3.5" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
</svg>
</button>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</div>
</div>
</div>
</section>
</MotionConfig>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/floating-toolbar-that-expands-into-a-search-field.json
The bar's `width` is animated between two fixed pixel values with a spring, and `overflow-hidden` clips the content while it moves. The two sets of controls each have a fixed inner width so they do not reflow during the change.
Fixed values are simpler and never flicker on first paint. If your tools change at run time, measure the inner row with a ResizeObserver and animate the bar to that width plus its padding.
It removes the leaving controls from the layout at once, so the incoming ones can take their place while the old ones are still fading. Without it the two sets would sit side by side for a moment.
No. A press outside only closes it when the field is empty. With text in the field it stays open until you press Escape or the back arrow.
Change the wrapper from `absolute inset-x-3 bottom-4` to `fixed inset-x-3 bottom-4 z-50`. It is drawn inside the window here so that it shows correctly in a preview.
Adapted from Toolbar Dynamic in Motion Primitives, used under its open-source licence. Licences and credits
Related: Floating Toolbar Expanding from a Pill with Tool Options, Magnifying Dock with Tooltips and Idle Wave, Search Field with Grouped Results Dropdown.
More docks and floating menus or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.