Search box
View .mdSearch bars are one of the most used components in the system. It allows the users to access the right component with ease.
Default
Loading interactive example. Full source is available below.
View example source
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import type { SearchBoxProps } from "@coreai-ds/react-components";
import type { JSXElement } from "@coreai-ds/react-components";
import { Field } from "@coreai-ds/react-components";
import { SearchBox } from "@coreai-ds/react-components";
import * as React from 'react';
export const Default = (props: SearchBoxProps): JSXElement => {
return (<Field label="Sample SearchBox">
<SearchBox {...props}/>
</Field>);
};
export default Default;
Resources
Behavior and states
- The focus animation appears once the searchbox receives focus.
- The Close button is only present when there’s content in the searchbox.
- Recent topics appear on focus, then suggested results replace them as a query is entered.
- At smaller viewport sizes, the searchbox can be redrawn as a magnifying glass icon that activates a full-width searchbox.
- The search bar must be flexible, allow for dismissal and filtering to provide the best experience.
- Search results are populated in a combo box dropdown that changes and repopulates as the user types.
API referenceGenerated CoreAI prop and type summaries
API
Generated CoreAI prop summaries, not replacement type declarations. Most native DOM/event and ARIA props are omitted. ? means optional; JSDoc shows defaults, deprecations, and conditional requirements. Defaults assume the standard CoreAI provider.
SearchBox API
interface SearchBoxProps {
/**
* Controls the colors and borders of the input.
* @default 'filled-darker'
*/
appearance?: "outline" | "filled-darker" | "filled-lighter" | "underline" | "filled-darker-shadow" | "filled-lighter-shadow";
as?: "input";
/**
* Input can't have children.
*/
children?: undefined;
className?: string;
/**
* Element after the input text, within the input border
*/
contentAfter?: WithSlotShorthandValue<{ as?: "span"; } & Omit<Omit<DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>, "ref"> & { ref?: ((instance: HTMLSpanElement | null) => void) | RefObject<HTMLSpanElement> | null; }, "children"> & { children?: any; }> | null | undefined;
/**
* Element before the input text, within the input border
*/
contentBefore?: WithSlotShorthandValue<{ as?: "span"; } & Omit<Omit<DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>, "ref"> & { ref?: ((instance: HTMLSpanElement | null) => void) | RefObject<HTMLSpanElement> | null; }, "children"> & { children?: any; }> | null | undefined;
/**
* Default value of the input. Provide this if the input should be an uncontrolled component
* which tracks its current state internally; otherwise, use `value`.
*
* (This prop is mutually exclusive with `value`.)
*/
defaultValue?: string;
/**
* Last element in the input, within the input border
*/
dismiss?: WithSlotShorthandValue<{ as?: "span"; } & Omit<Omit<DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>, "ref"> & { ref?: ((instance: HTMLSpanElement | null) => void) | RefObject<HTMLSpanElement> | null; }, "children"> & { children?: any; }> | null | undefined;
/**
* The actual `<input>` element. `type="text"` will be automatically applied unless overridden.
*
* This is the "primary" slot, so native props specified directly on the `<Input>` will go here
* (except `className` and `style`, which go to the `root` slot). The top-level `ref` will
* also go here.
*/
input?: ({ as?: "input"; } & Omit<Omit<DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>, "ref"> & { ref?: ((instance: HTMLInputElement | null) => void) | RefObject<HTMLInputElement> | null; }, "children"> & { children?: undefined; }) | undefined;
/**
* Custom onChange callback.
* Will be traditionally supplied with a React.ChangeEvent<HTMLInputElement> for usual character entry.
* When the dismiss button is clicked, this will be called with an event of type React.MouseEvent<HTMLSpanElement>
* and an empty string as the `value` property of the data parameter
*/
onChange?: ((event: SearchBoxChangeEvent, data: InputOnChangeData) => void);
ref?: Ref<HTMLInputElement>;
/**
* Wrapper element which visually appears to be the input and is used for borders, focus styling, etc.
* (A wrapper is needed to properly position `contentBefore` and `contentAfter` relative to `input`.)
*
* The root slot receives the `className` and `style` specified directly on the `<Input>`.
* All other top-level native props will be applied to the primary slot, `input`.
*/
root?: NonNullable<WithSlotShorthandValue<{ as?: "span"; } & Omit<Omit<DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>, "ref"> & { ref?: ((instance: HTMLSpanElement | null) => void) | RefObject<HTMLSpanElement> | null; }, "children"> & { children?: any; }> | null> | undefined;
/**
* Size of the input (changes the font size and spacing).
* @default 'medium'
*/
size?: "small" | "medium" | "large";
style?: CSSProperties;
/**
* An input can have different text-based [types](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Input#input_types)
* based on the type of value the user will enter.
*
* Note that no custom styling is currently applied for alternative types, and some types may
* activate browser-default styling which does not match the Fluent design language.
*
* (For non-text-based types such as `button` or `checkbox`, use the appropriate component or an
* `<input>` element instead.)
* @default 'text'
*/
type?: "number" | "search" | "time" | "text" | "tel" | "url" | "email" | "date" | "datetime-local" | "month" | "password" | "week";
/**
* Current value of the input. Provide this if the input is a controlled component where you
* are maintaining its current state; otherwise, use `defaultValue`.
*
* (This prop is mutually exclusive with `defaultValue`.)
*/
value?: string;
}
Implementation
CoreAI 0.12.0; Fluent 9.74.9.
Import components from @coreai-ds/react-components. CoreAI guidance above takes precedence over API demonstration examples.
Appearance
Loading interactive example. Full source is available below.
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import type { JSXElement } from "@coreai-ds/react-components";
import { makeStyles } from "@coreai-ds/react-components";
import { tokens } from "@coreai-ds/react-components";
import { Field } from "@coreai-ds/react-components";
import { SearchBox } from "@coreai-ds/react-components";
import { mergeClasses } from "@coreai-ds/react-components";
import * as React from 'react';
const useStyles = makeStyles({
base: {
display: 'flex',
flexDirection: 'column',
},
filledLighter: {
backgroundColor: tokens.colorNeutralBackgroundInverted,
},
filledLighterLabel: {
color: tokens.colorNeutralForegroundInverted2,
},
filledDarker: {
backgroundColor: tokens.colorNeutralBackgroundInverted,
},
filledDarkerLabel: {
color: tokens.colorNeutralForegroundInverted2,
},
fieldWrapper: {
padding: `${tokens.spacingVerticalMNudge} ${tokens.spacingHorizontalMNudge}`,
},
});
export const Appearance = (): JSXElement => {
const styles = useStyles();
return (<div className={styles.base}>
<Field className={styles.fieldWrapper} label="Outline appearance (default)">
<SearchBox appearance="outline"/>
</Field>
<Field className={styles.fieldWrapper} label="Underline appearance">
<SearchBox appearance="underline"/>
</Field>
<Field className={mergeClasses(styles.fieldWrapper, styles.filledLighter)} label={{ children: 'Filled lighter appearance', className: styles.filledLighterLabel }}>
<SearchBox appearance="filled-lighter"/>
</Field>
<Field className={mergeClasses(styles.fieldWrapper, styles.filledDarker)} label={{ children: 'Filled darker appearance', className: styles.filledDarkerLabel }}>
<SearchBox appearance="filled-darker"/>
</Field>
</div>);
};
export default Appearance;
Content before/after
Loading interactive example. Full source is available below.
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import type { JSXElement } from "@coreai-ds/react-components";
import { makeStyles } from "@coreai-ds/react-components";
import { tokens } from "@coreai-ds/react-components";
import { Field } from "@coreai-ds/react-components";
import { SearchBox } from "@coreai-ds/react-components";
import { PersonRegular } from "@fluentui/react-icons";
import * as React from 'react';
import type { ButtonProps } from "@coreai-ds/react-components";
import { Button } from "@coreai-ds/react-components";
import { MicRegular } from "@fluentui/react-icons";
import { Text } from "@coreai-ds/react-components";
const useStyles = makeStyles({
root: {
display: 'flex',
flexDirection: 'column',
},
fieldWrapper: {
padding: `${tokens.spacingVerticalMNudge} ${tokens.spacingHorizontalMNudge}`,
},
});
const MicButton: React.FC<ButtonProps> = props => {
return <Button {...props} appearance="transparent" icon={<MicRegular />} size="small"/>;
};
export const ContentBeforeAfter = (): JSXElement => {
const styles = useStyles();
return (<div className={styles.root}>
<Field className={styles.fieldWrapper} label="Search by name" hint={<>
A SearchBox with a custom icon in the <code>contentBefore</code> slot.
</>}>
<SearchBox contentBefore={<PersonRegular />}/>
</Field>
<Field className={styles.fieldWrapper} label="Search by voice" hint={<>
A SearchBox with a button in the <code>contentAfter</code> slot.
</>}>
<SearchBox contentAfter={<MicButton aria-label="Search by voice"/>}/>
</Field>
<Field className={styles.fieldWrapper} label="Search with filter" hint={<>
A SearchBox with a presentational value in the <code>contentBefore</code> slot and another presentational
value in the <code>contentAfter</code> slot.
</>}>
<SearchBox contentBefore={<Text size={400}>Search:</Text>} contentAfter={<Text size={400}>Filter</Text>}/>
</Field>
</div>);
};
export default ContentBeforeAfter;
Controlled
Loading interactive example. Full source is available below.
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import type { JSXElement } from "@coreai-ds/react-components";
import * as React from 'react';
import type { SearchBoxChangeEvent } from "@coreai-ds/react-components";
import type { InputOnChangeData } from "@coreai-ds/react-components";
import { Field } from "@coreai-ds/react-components";
import { SearchBox } from "@coreai-ds/react-components";
export const Controlled = (): JSXElement => {
const [value, setValue] = React.useState('initial value');
const [valid, setValid] = React.useState(true);
const onChange: (ev: SearchBoxChangeEvent, data: InputOnChangeData) => void = (_, data) => {
if (data.value.length <= 20) {
setValue(data.value);
setValid(true);
}
else {
setValid(false);
}
};
return (<Field label="Controlled SearchBox limiting the value to 20 characters" validationState={valid ? 'none' : 'warning'} validationMessage={valid ? '' : 'Input is limited to 20 characters.'}>
<SearchBox value={value} onChange={onChange}/>
</Field>);
};
export default Controlled;
Disabled
Loading interactive example. Full source is available below.
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import type { JSXElement } from "@coreai-ds/react-components";
import { Field } from "@coreai-ds/react-components";
import { SearchBox } from "@coreai-ds/react-components";
import * as React from 'react';
export const Disabled = (): JSXElement => {
return (<Field label="Disabled SearchBox">
<SearchBox disabled defaultValue="disabled value"/>
</Field>);
};
export default Disabled;
Placeholder
Loading interactive example. Full source is available below.
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import type { JSXElement } from "@coreai-ds/react-components";
import { Field } from "@coreai-ds/react-components";
import { SearchBox } from "@coreai-ds/react-components";
import * as React from 'react';
export const Placeholder = (): JSXElement => {
return (<Field label="SearchBox with a placeholder">
<SearchBox placeholder="This is a placeholder"/>
</Field>);
};
export default Placeholder;
Size
Loading interactive example. Full source is available below.
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import type { JSXElement } from "@coreai-ds/react-components";
import { makeStyles } from "@coreai-ds/react-components";
import { tokens } from "@coreai-ds/react-components";
import { Field } from "@coreai-ds/react-components";
import { SearchBox } from "@coreai-ds/react-components";
import * as React from 'react';
const useStyles = makeStyles({
root: {
display: 'flex',
flexDirection: 'column',
},
fieldWrapper: {
padding: `${tokens.spacingVerticalMNudge} ${tokens.spacingHorizontalMNudge}`,
},
});
export const Size = (): JSXElement => {
const styles = useStyles();
return (<div className={styles.root}>
<Field className={styles.fieldWrapper} label="Small SearchBox">
<SearchBox size="small"/>
</Field>
<Field className={styles.fieldWrapper} label="Medium SearchBox">
<SearchBox size="medium"/>
</Field>
<Field className={styles.fieldWrapper} label="Large SearchBox">
<SearchBox size="large"/>
</Field>
</div>);
};
export default Size;
Typeahead
Loading interactive example. Full source is available below.
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import type { JSXElement } from "@coreai-ds/react-components";
import { makeStyles } from "@coreai-ds/react-components";
import { tokens } from "@coreai-ds/react-components";
import * as React from 'react';
import { useId } from "@coreai-ds/react-components";
import { useTypingAnnounce } from "@coreai-ds/react-components";
import type { SearchBoxChangeEvent } from "@coreai-ds/react-components";
import type { InputOnChangeData } from "@coreai-ds/react-components";
import { AriaLiveAnnouncer } from "@coreai-ds/react-components";
import { SearchBox } from "@coreai-ds/react-components";
import { mergeClasses } from "@coreai-ds/react-components";
import { Spinner } from "@coreai-ds/react-components";
const useStyles = makeStyles({
root: {
position: 'relative',
maxWidth: '400px',
},
listbox: {
backgroundColor: tokens.colorNeutralBackground1,
border: `1px solid ${tokens.colorNeutralStroke1}`,
borderRadius: tokens.borderRadiusMedium,
boxShadow: tokens.shadow16,
boxSizing: 'border-box',
listStyleType: 'none',
margin: 0,
padding: `${tokens.spacingVerticalXS} 0`,
position: 'absolute',
width: '100%',
zIndex: 1000,
},
listboxHidden: {
display: 'none',
},
option: {
alignItems: 'center',
cursor: 'pointer',
display: 'flex',
gap: tokens.spacingHorizontalS,
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`,
':hover': {
backgroundColor: tokens.colorNeutralBackground1Hover,
},
},
optionFocused: {
backgroundColor: tokens.colorNeutralBackground1Selected,
outline: 'none',
},
spinnerWrapper: {
alignItems: 'center',
display: 'flex',
gap: tokens.spacingHorizontalS,
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`,
color: tokens.colorNeutralForeground3,
},
noResults: {
color: tokens.colorNeutralForeground3,
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`,
},
});
type SearchResult = {
id: string;
label: string;
};
// Simulated async search function
const fetchResults = (query: string): Promise<SearchResult[]> => {
const allResults: SearchResult[] = [
{ id: '1', label: 'Accessibility in Fluent UI' },
{ id: '2', label: 'Button component' },
{ id: '3', label: 'Combobox with filtering' },
{ id: '4', label: 'Dark mode theming' },
{ id: '5', label: 'Fluent UI v9 migration guide' },
{ id: '6', label: 'Form validation patterns' },
{ id: '7', label: 'Grid layout examples' },
{ id: '8', label: 'High contrast support' },
{ id: '9', label: 'Icon library overview' },
{ id: '10', label: 'Jest testing utilities' },
];
return new Promise(resolve => {
setTimeout(() => {
resolve(allResults.filter(r => r.label.toLowerCase().includes(query.toLowerCase())));
}, 500);
});
};
const DEBOUNCE_MS = 300;
export const Typeahead = (): JSXElement => {
const styles = useStyles();
const [query, setQuery] = React.useState('');
const [results, setResults] = React.useState<SearchResult[]>([]);
const [isLoading, setIsLoading] = React.useState(false);
const [isOpen, setIsOpen] = React.useState(false);
const [focusedIndex, setFocusedIndex] = React.useState(-1);
const [hideActiveDescendant, setHideActiveDescendant] = React.useState(false);
const [selectedId, setSelectedId] = React.useState<string | null>(null);
const listboxId = useId();
const announceId = useId('typeahead');
const { typingAnnounce, inputRef } = useTypingAnnounce<HTMLInputElement>();
const optionRefs = React.useRef<(HTMLLIElement | null)[]>([]);
const cancelRef = React.useRef<(() => void) | null>(null);
// Debounced search when query changes
React.useEffect(() => {
if (!query) {
setResults([]);
setIsOpen(false);
setIsLoading(false);
return;
}
setIsLoading(true);
setIsOpen(true);
setFocusedIndex(-1);
const debounceTimer = setTimeout(() => {
let cancelled = false;
fetchResults(query).then(data => {
if (!cancelled) {
setResults(data);
optionRefs.current = new Array(data.length).fill(null);
setIsLoading(false);
}
});
// Store cancel in the ref so the cleanup can access it
cancelRef.current = () => {
cancelled = true;
};
}, DEBOUNCE_MS);
return () => {
clearTimeout(debounceTimer);
cancelRef.current?.();
cancelRef.current = null;
};
}, [query]);
React.useEffect(() => {
if (!isOpen || isLoading) {
return;
}
if (results.length > 0) {
typingAnnounce(`${results.length} result${results.length !== 1 ? 's' : ''} available`, {
batchId: announceId,
});
}
else if (query.length > 0) {
typingAnnounce('No results found', { batchId: announceId });
}
}, [isLoading, isOpen, results.length, query.length, typingAnnounce, announceId]);
// Keep focused option scrolled into view
React.useEffect(() => {
if (focusedIndex >= 0) {
optionRefs.current[focusedIndex]?.scrollIntoView({ block: 'nearest' });
}
}, [focusedIndex]);
const handleChange = (_: SearchBoxChangeEvent, data: InputOnChangeData) => {
setQuery(data.value);
setSelectedId(null);
if (!data.value) {
return;
}
// useTypingAnnounce waits until the user pauses typing before announcing,
// preventing interference with screen reader keyboard echo.
typingAnnounce('Searching…', { batchId: announceId });
};
const handleSelect = (result: SearchResult) => {
setSelectedId(result.id);
setQuery(result.label);
setIsOpen(false);
setFocusedIndex(-1);
inputRef.current?.focus();
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!isOpen) {
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setHideActiveDescendant(false);
setFocusedIndex(prev => Math.min(prev + 1, results.length - 1));
}
else if (e.key === 'ArrowUp') {
e.preventDefault();
setHideActiveDescendant(false);
setFocusedIndex(prev => Math.max(prev - 1, -1));
}
else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
setHideActiveDescendant(true);
}
else if (e.key === 'Enter' && focusedIndex >= 0) {
e.preventDefault();
handleSelect(results[focusedIndex]);
}
else if (e.key === 'Escape') {
setIsOpen(false);
setFocusedIndex(-1);
setHideActiveDescendant(false);
}
};
const handleBlur = (e: React.FocusEvent<HTMLSpanElement>) => {
// Close if focus leaves both the input and the listbox
const relatedTarget = e.relatedTarget as Node | null;
const listboxEl = document.getElementById(listboxId);
if (!e.currentTarget.contains(relatedTarget) && !listboxEl?.contains(relatedTarget)) {
setIsOpen(false);
setFocusedIndex(-1);
}
};
const showDropdown = isOpen && (isLoading || results.length > 0);
const noResults = isOpen && !isLoading && query.length > 0 && results.length === 0;
const activedescendant = focusedIndex >= 0 && !hideActiveDescendant ? `${listboxId}-option-${focusedIndex}` : undefined;
return (<AriaLiveAnnouncer>
<div className={styles.root}>
<SearchBox ref={inputRef} aria-autocomplete="list" aria-controls={listboxId} aria-expanded={showDropdown || noResults} aria-activedescendant={activedescendant} placeholder="Search..." value={query} onChange={handleChange} onKeyDown={handleKeyDown} root={{ onBlur: handleBlur }}/>
<ul id={listboxId} role="listbox" aria-label="Search results" className={mergeClasses(styles.listbox, !showDropdown && !noResults && styles.listboxHidden)}>
{isLoading ? (<li className={styles.spinnerWrapper}>
<Spinner size="tiny" label="Loading results…" labelPosition="after"/>
</li>) : results.length > 0 ? (results.map((result, index) => (<li key={result.id} id={`${listboxId}-option-${index}`} ref={el => {
optionRefs.current[index] = el;
}} role="option" aria-selected={selectedId === result.id} className={mergeClasses(styles.option, focusedIndex === index && styles.optionFocused)} onMouseDown={e => {
// Prevent input blur before click registers
e.preventDefault();
}} onClick={() => handleSelect(result)}>
{result.label}
</li>))) : (<li className={styles.noResults}>No results found</li>)}
</ul>
</div>
</AriaLiveAnnouncer>);
};
export default Typeahead;