展开搜索框
圆形的搜索图标点击后伸展成完整的输入框,内容为空时离开又会收回去。
用 React 和 CSS 制作可展开的搜索框。收起时是一个 44px 的圆形,内含搜索图标按钮和一个宽度为 0 的 input。点击图标后进入展开状态:容器宽度在 360ms 内从 44px 过渡到 240px,缓动 cubic-bezier(0.16, 1, 0.3, 1),input 淡入,并在过渡开始时把焦点移到 input 上。input 失焦且内容为空时,或按下 Escape 时,再次收起,并把焦点还给图标按钮。图标固定在左侧,避免跳动。按钮设置 aria-expanded 和 aria-controls。在 prefers-reduced-motion 下展开和收起不做宽度过渡。占位文字为“搜索动效…”。
源代码
'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; }
}