Expanding Search
A round search icon stretches into a full field when tapped and folds back when you leave it empty.
Create an expanding search field in React and CSS. Collapsed, it is a 44px circle with a search icon button and an input of width 0. Clicking the icon sets an open state: the container width transitions from 44px to 240px over 360ms with cubic-bezier(0.16, 1, 0.3, 1), the input fades in, and focus moves to it once the transition starts. On blur with an empty value, or on Escape, collapse it again and return focus to the icon button. Keep the icon fixed on the left so it does not jump. Give the button aria-expanded and aria-controls. Under prefers-reduced-motion, open and close without the width transition. The placeholder reads "Search motions…".
Source
'use client'
import { useId, useRef, useState } from 'react'
import s from './demo.module.css'
export default function ExpandingSearch({ labels }: { labels: Record<string, string> }) {
const [open, setOpen] = useState(false)
const input = useRef<HTMLInputElement>(null)
const button = useRef<HTMLButtonElement>(null)
const id = useId()
const close = (refocus: boolean) => {
setOpen(false)
if (refocus) button.current?.focus()
}
return (
<div className={s.search} data-open={open || undefined}>
<button
ref={button}
type="button"
className={s.icon}
aria-label={labels.open}
aria-expanded={open}
aria-controls={id}
onClick={() => {
setOpen(true)
input.current?.focus()
}}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="11" cy="11" r="6.5" />
<path d="M16 16l4 4" />
</svg>
</button>
<input
ref={input}
id={id}
type="search"
className={s.input}
placeholder={labels.placeholder}
tabIndex={open ? 0 : -1}
onBlur={(e) => !e.currentTarget.value && close(false)}
onKeyDown={(e) => e.key === 'Escape' && close(true)}
/>
</div>
)
}
.search {
display: flex;
align-items: center;
width: 44px;
height: 44px;
overflow: hidden;
border: 1px solid var(--stage-rule);
border-radius: 999px;
background: var(--stage-surface);
transition: width 360ms var(--ease-out), border-color var(--dur-short);
}
.search[data-open] { width: min(240px, 80vw); border-color: var(--stage-accent); }
.icon {
display: grid;
place-items: center;
flex: none;
width: 42px;
height: 42px;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--stage-ink);
cursor: pointer;
}
.icon:focus-visible { outline: 2px solid var(--color-focus); outline-offset: -4px; }
.icon svg {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
}
.input {
flex: 1;
min-width: 0;
height: 100%;
padding: 0 14px 0 0;
border: 0;
outline: none;
background: transparent;
color: var(--stage-ink);
font: 400 var(--text-sm) / 1 var(--font-body);
opacity: 0;
transition: opacity 200ms var(--ease-out);
}
.input::placeholder { color: var(--stage-muted); }
.search[data-open] .input { opacity: 1; transition-delay: 120ms; }
@media (prefers-reduced-motion: reduce) {
.search, .input { transition: none; }
}