Forms
SearchField
Search field without suggestions, for filtering what is already on screen
Preview
It has no list. It is for filtering what is already on screen, like the rows of a table. To pick from suggestions, the component is Search.
size
placeholder
disabled
Code
import css from "./SearchField.module.css";
import { useId, useRef, useState } from "react";
import { MagnifyingGlassIcon } from "@phosphor-icons/react/dist/csr/MagnifyingGlass";
import { XIcon } from "@phosphor-icons/react/dist/csr/X";
type Size = "xs" | "sm" | "md" | "lg";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface SearchFieldProps {
size: Size | ResponsiveSize;
// Named after the attribute it becomes: the magnifier and the placeholder are hints, not a name.
"aria-label": string;
placeholder?: string;
name?: string;
clearAriaLabel: string;
disabled?: boolean;
value: string;
onChange: (value: string) => void;
}
function getSizeClasses(size: Size | ResponsiveSize): string {
if (typeof size === "string") {
return css[size];
}
const classes = [css[`${size.base}_base`]];
if (size.mobile) classes.push(css[`${size.mobile}_mobile`]);
if (size.tablet) classes.push(css[`${size.tablet}_tablet`]);
if (size.desktop) classes.push(css[`${size.desktop}_desktop`]);
if (size.wide) classes.push(css[`${size.wide}_wide`]);
return classes.join(" ");
}
export default function SearchField({
size,
"aria-label": ariaLabel,
placeholder,
name,
clearAriaLabel,
disabled,
value,
onChange,
}: SearchFieldProps) {
// Nothing points at it: without an id or a name, Chrome flags the field as one autofill can't
// identify.
const fieldId = useId();
const field = useRef<HTMLInputElement>(null);
const searchField = useRef<HTMLDivElement>(null);
const [tabbed, setTabbed] = useState(false);
const pointer = useRef(false);
const clear = () => {
onChange("");
field.current?.focus();
};
const onFocus = () => setTabbed(!pointer.current);
// Focus moving to the clear button is not leaving the field: it hands it back.
const onBlur = (ev: React.FocusEvent) => {
if (searchField.current?.contains(ev.relatedTarget)) return;
setTabbed(false);
pointer.current = false;
};
const onPointerDown = () => {
pointer.current = true;
};
const handleChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
onChange(ev.target.value);
};
const onKeyDown = (ev: React.KeyboardEvent<HTMLInputElement>) => {
if (ev.key === "Escape" && value) {
ev.preventDefault();
clear();
}
};
return (
<div
ref={searchField}
onPointerDown={onPointerDown}
className={`
${css.searchfield}
${getSizeClasses(size)}
${tabbed ? css.tabbed : ""}
${disabled ? css.disabled : ""}
`}
>
<MagnifyingGlassIcon className={css.searchfield_icon} aria-hidden="true" />
<input
ref={field}
id={fieldId}
className={css.searchfield_input}
type="search"
name={name}
autoComplete="off"
aria-label={ariaLabel}
placeholder={placeholder}
disabled={disabled}
value={value}
onFocus={onFocus}
onBlur={onBlur}
onKeyDown={onKeyDown}
onChange={handleChange}
/>
{value && (
<button
type="button"
className={css.searchfield_clear}
aria-label={clearAriaLabel}
onClick={clear}
>
<XIcon weight="bold" />
</button>
)}
</div>
);
}
.searchfield {
position: relative;
display: flex;
align-items: center;
width: 100%;
height: var(--searchfield-height);
gap: var(--space-8);
padding: 0 var(--space-12);
border: var(--border-surface) solid var(--color-border-control);
border-radius: var(--radius-pill);
color: var(--color-text);
font-size: var(--text-14);
-webkit-tap-highlight-color: transparent;
&.xs {
--searchfield-height: var(--size-36);
}
&.sm {
--searchfield-height: var(--size-40);
}
&.md {
--searchfield-height: var(--size-44);
}
&.lg {
--searchfield-height: var(--size-48);
}
&.xs_base {
--searchfield-height: var(--size-36);
}
&.sm_base {
--searchfield-height: var(--size-40);
}
&.md_base {
--searchfield-height: var(--size-44);
}
&.lg_base {
--searchfield-height: var(--size-48);
}
@media (min-width: 480px) {
&.xs_mobile {
--searchfield-height: var(--size-36);
}
&.sm_mobile {
--searchfield-height: var(--size-40);
}
&.md_mobile {
--searchfield-height: var(--size-44);
}
&.lg_mobile {
--searchfield-height: var(--size-48);
}
}
@media (min-width: 768px) {
&.xs_tablet {
--searchfield-height: var(--size-36);
}
&.sm_tablet {
--searchfield-height: var(--size-40);
}
&.md_tablet {
--searchfield-height: var(--size-44);
}
&.lg_tablet {
--searchfield-height: var(--size-48);
}
}
@media (min-width: 1024px) {
&.xs_desktop {
--searchfield-height: var(--size-36);
}
&.sm_desktop {
--searchfield-height: var(--size-40);
}
&.md_desktop {
--searchfield-height: var(--size-44);
}
&.lg_desktop {
--searchfield-height: var(--size-48);
}
}
@media (min-width: 1440px) {
&.xs_wide {
--searchfield-height: var(--size-36);
}
&.sm_wide {
--searchfield-height: var(--size-40);
}
&.md_wide {
--searchfield-height: var(--size-44);
}
&.lg_wide {
--searchfield-height: var(--size-48);
}
}
/* focus-within, not the input alone: the contour would blink while focus sits on the clear
button. */
&:focus-within {
border-color: var(--color-contour-accent);
/* On text fields :focus-visible also fires on click, so the Tab
signal needs its own class. An inset shadow, not border-width: a
thicker border would push the icon and the clear button. */
&.tabbed {
border-color: var(--color-accent);
box-shadow: inset 0 0 0 1px var(--color-accent);
}
}
.searchfield_icon {
flex-shrink: 0;
font-size: 20px;
}
.searchfield_input {
flex: 1;
min-width: 0;
height: 100%;
/* Reserved whether the clear button is drawn or not, so the text never
jumps when it appears. */
padding: 0 var(--space-32) 0 0;
background: none;
border: 0;
/* type="search" brings the browser's own styling and clear button; the pill draws both. */
appearance: none;
font-family: var(--font-body);
font-size: inherit;
font-weight: var(--weight-500);
color: inherit;
text-overflow: ellipsis;
/* iOS zooms in on a focused field under 16px; a phone's short side is under 600, a tablet's
744+. */
@media (pointer: coarse) and (max-width: 599px),
(pointer: coarse) and (max-height: 599px) {
font-size: max(1em, 16px);
}
&::-webkit-search-cancel-button,
&::-webkit-search-decoration {
appearance: none;
}
/* Chrome's default placeholder fails contrast in light; Firefox fades it
with opacity, hence opacity: 1. */
&::placeholder {
color: var(--color-text-secondary);
opacity: 1;
}
/* Beats the global ring at (0,2,0); the pill carries the focus signal. */
&:focus-visible {
outline: none;
}
}
.searchfield_clear {
position: absolute;
top: 50%;
right: var(--space-8);
transform: translateY(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--size-28);
height: var(--size-28);
background: none;
border: none;
border-radius: var(--radius-full);
cursor: pointer;
font-size: var(--text-16);
color: inherit;
transition: background 0.2s ease;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
@media (hover: hover) {
&:hover {
background: color-mix(in srgb, var(--color-text) 8%, transparent);
}
}
&:active {
background: color-mix(in srgb, var(--color-text) 15%, transparent);
}
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
svg {
width: 1em;
height: 1em;
}
}
&.disabled {
opacity: 0.5;
pointer-events: none;
}
}
<script setup lang="ts">
import { computed, ref, useId, useTemplateRef } from "vue";
import { PhMagnifyingGlass, PhX } from "@phosphor-icons/vue";
type Size = "xs" | "sm" | "md" | "lg";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface SearchFieldProps {
size: Size | ResponsiveSize;
// Named after the attribute it becomes: the magnifier and the placeholder are hints, not a name.
ariaLabel: string;
placeholder?: string;
name?: string;
clearAriaLabel: string;
disabled?: boolean;
}
const { size } = defineProps<SearchFieldProps>();
const model = defineModel<string>({ required: true });
// Nothing points at it: without an id or a name, Chrome flags the field as one autofill can't
// identify.
const fieldId = useId();
const field = useTemplateRef<HTMLInputElement>("field");
const searchField = useTemplateRef<HTMLDivElement>("searchField");
const tabbed = ref(false);
let pointer = false;
const sizeClasses = computed(() => {
if (typeof size === "string") {
return size;
}
const classes = [`${size.base}_base`];
if (size.mobile) classes.push(`${size.mobile}_mobile`);
if (size.tablet) classes.push(`${size.tablet}_tablet`);
if (size.desktop) classes.push(`${size.desktop}_desktop`);
if (size.wide) classes.push(`${size.wide}_wide`);
return classes.join(" ");
});
const clear = () => {
model.value = "";
field.value?.focus();
};
const onFocus = () => {
tabbed.value = !pointer;
};
// Focus moving to the clear button is not leaving the field: it hands it back.
const onBlur = (ev: FocusEvent) => {
if (searchField.value?.contains(ev.relatedTarget as Node | null)) return;
tabbed.value = false;
pointer = false;
};
const onPointerDown = () => {
pointer = true;
};
const handleChange = (ev: Event) => {
model.value = (ev.target as HTMLInputElement).value;
};
const onKeyDown = (ev: KeyboardEvent) => {
if (ev.key === "Escape" && model.value) {
ev.preventDefault();
clear();
}
};
</script>
<template>
<div
ref="searchField"
class="searchfield"
:class="[sizeClasses, { tabbed, disabled }]"
@pointerdown="onPointerDown"
>
<PhMagnifyingGlass class="searchfield_icon" aria-hidden="true" />
<input
ref="field"
:id="fieldId"
class="searchfield_input"
type="search"
:name="name"
autocomplete="off"
:aria-label="ariaLabel"
:placeholder="placeholder"
:disabled="disabled"
:value="model"
@focus="onFocus"
@blur="onBlur"
@keydown="onKeyDown"
@input="handleChange"
/>
<button
v-if="model"
type="button"
class="searchfield_clear"
:aria-label="clearAriaLabel"
@click="clear"
>
<PhX weight="bold" />
</button>
</div>
</template>
<style scoped>
.searchfield {
position: relative;
display: flex;
align-items: center;
width: 100%;
height: var(--searchfield-height);
gap: var(--space-8);
padding: 0 var(--space-12);
border: var(--border-surface) solid var(--color-border-control);
border-radius: var(--radius-pill);
color: var(--color-text);
font-size: var(--text-14);
-webkit-tap-highlight-color: transparent;
&.xs {
--searchfield-height: var(--size-36);
}
&.sm {
--searchfield-height: var(--size-40);
}
&.md {
--searchfield-height: var(--size-44);
}
&.lg {
--searchfield-height: var(--size-48);
}
&.xs_base {
--searchfield-height: var(--size-36);
}
&.sm_base {
--searchfield-height: var(--size-40);
}
&.md_base {
--searchfield-height: var(--size-44);
}
&.lg_base {
--searchfield-height: var(--size-48);
}
@media (min-width: 480px) {
&.xs_mobile {
--searchfield-height: var(--size-36);
}
&.sm_mobile {
--searchfield-height: var(--size-40);
}
&.md_mobile {
--searchfield-height: var(--size-44);
}
&.lg_mobile {
--searchfield-height: var(--size-48);
}
}
@media (min-width: 768px) {
&.xs_tablet {
--searchfield-height: var(--size-36);
}
&.sm_tablet {
--searchfield-height: var(--size-40);
}
&.md_tablet {
--searchfield-height: var(--size-44);
}
&.lg_tablet {
--searchfield-height: var(--size-48);
}
}
@media (min-width: 1024px) {
&.xs_desktop {
--searchfield-height: var(--size-36);
}
&.sm_desktop {
--searchfield-height: var(--size-40);
}
&.md_desktop {
--searchfield-height: var(--size-44);
}
&.lg_desktop {
--searchfield-height: var(--size-48);
}
}
@media (min-width: 1440px) {
&.xs_wide {
--searchfield-height: var(--size-36);
}
&.sm_wide {
--searchfield-height: var(--size-40);
}
&.md_wide {
--searchfield-height: var(--size-44);
}
&.lg_wide {
--searchfield-height: var(--size-48);
}
}
/* focus-within, not the input alone: the contour would blink while focus sits on the clear
button. */
&:focus-within {
border-color: var(--color-contour-accent);
/* On text fields :focus-visible also fires on click, so the Tab
signal needs its own class. An inset shadow, not border-width: a
thicker border would push the icon and the clear button. */
&.tabbed {
border-color: var(--color-accent);
box-shadow: inset 0 0 0 1px var(--color-accent);
}
}
.searchfield_icon {
flex-shrink: 0;
font-size: 20px;
}
.searchfield_input {
flex: 1;
min-width: 0;
height: 100%;
/* Reserved whether the clear button is drawn or not, so the text never
jumps when it appears. */
padding: 0 var(--space-32) 0 0;
background: none;
border: 0;
/* type="search" brings the browser's own styling and clear button; the pill draws both. */
appearance: none;
font-family: var(--font-body);
font-size: inherit;
font-weight: var(--weight-500);
color: inherit;
text-overflow: ellipsis;
/* iOS zooms in on a focused field under 16px; a phone's short side is under 600, a tablet's
744+. */
@media (pointer: coarse) and (max-width: 599px),
(pointer: coarse) and (max-height: 599px) {
font-size: max(1em, 16px);
}
&::-webkit-search-cancel-button,
&::-webkit-search-decoration {
appearance: none;
}
/* Chrome's default placeholder fails contrast in light; Firefox fades it
with opacity, hence opacity: 1. */
&::placeholder {
color: var(--color-text-secondary);
opacity: 1;
}
/* Beats the global ring at (0,2,0); the pill carries the focus signal. */
&:focus-visible {
outline: none;
}
}
.searchfield_clear {
position: absolute;
top: 50%;
right: var(--space-8);
transform: translateY(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--size-28);
height: var(--size-28);
background: none;
border: none;
border-radius: var(--radius-full);
cursor: pointer;
font-size: var(--text-16);
color: inherit;
transition: background 0.2s ease;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
@media (hover: hover) {
&:hover {
background: color-mix(in srgb, var(--color-text) 8%, transparent);
}
}
&:active {
background: color-mix(in srgb, var(--color-text) 15%, transparent);
}
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
svg {
width: 1em;
height: 1em;
}
}
&.disabled {
opacity: 0.5;
pointer-events: none;
}
}
</style>
Props
| Prop | Type | Required | Description |
|---|---|---|---|
| size | "xs" | "sm" | "md" | "lg" | ResponsiveSize | required | Field height (36/40/44/48, the Search ladder). The text stays at 14, as in TextField and Picker. Fixed or responsive. |
| aria-label | string | required | Name of the field for screen readers; the field draws no label. |
| placeholder | string | — | Hint inside the field while it is empty. Not a substitute for the name. |
| name | string | — | Field name when it lives in a form; without it, it is not submitted. |
| clearAriaLabel | string | required | Name of the clear button for screen readers; only drawn when there is content. |
| disabled | boolean | — | Disables the field (not editable, reduced opacity). |
| value | string | required | Field value (controlled). |
| onChange | (value: string) => void | required | Callback with the new value on every change. Escape and the clear button empty it. |