Search and Replace
An accessible search and replace panel for Tiptap editors. It provides live result highlighting, a one-based result counter, wrap-around navigation, match-case and whole-word options, regular expression search, and replace or replace-all actions.
The component owns the search state and editor commands, but deliberately does not own its position or open state. You can dock it in a corner, place it in a popover, or render it inline.
Requires the Find and Replace extension
Register @tiptap/extension-find-and-replace on the same editor. Without the extension, the panel
still renders but its controls are disabled by default. Set hideWhenUnavailable to true if the
panel should not render when the extension is missing.
Installation
Add the component via the Tiptap CLI:
npx @tiptap/cli@latest add search-and-replaceSetup
Register the Find and Replace extension in your editor. The UI component includes styles for .find-and-replace-result and .find-and-replace-result-current, so disable the extension's injected highlight CSS when you use the included SCSS.
import { FindAndReplace } from '@tiptap/extension-find-and-replace'
import { StarterKit } from '@tiptap/starter-kit'
const editor = useEditor({
immediatelyRender: false,
extensions: [
StarterKit,
FindAndReplace.configure({
injectCSS: false,
}),
],
})Search input changes are sent directly to the extension. The extension's searchDebounceMs option controls when results update; the UI does not add a second debounce.
Components
<SearchAndReplace />
The complete search and replace panel. Keep the component mounted and control it with open when you want the built-in Mod+F shortcut to reopen a closed panel.
Usage
'use client'
import { useState } from 'react'
import { EditorContent, EditorContext, useEditor } from '@tiptap/react'
import { FindAndReplace } from '@tiptap/extension-find-and-replace'
import { StarterKit } from '@tiptap/starter-kit'
import { SearchAndReplace, SearchAndReplaceButton } from '@/components/tiptap-ui/search-and-replace'
export default function MyEditor() {
const [isSearchOpen, setIsSearchOpen] = useState(false)
const editor = useEditor({
immediatelyRender: false,
extensions: [
StarterKit,
FindAndReplace.configure({
injectCSS: false,
}),
],
content: '<p>Search this document for Tiptap.</p>',
})
return (
<EditorContext.Provider value={{ editor }}>
<SearchAndReplaceButton
tabIndex={0}
aria-expanded={isSearchOpen}
data-active-state={isSearchOpen ? 'on' : 'off'}
onClick={() => setIsSearchOpen((current) => !current)}
/>
<div style={{ position: 'relative' }}>
<SearchAndReplace
open={isSearchOpen}
onOpen={() => setIsSearchOpen(true)}
onClose={() => setIsSearchOpen(false)}
style={{
position: 'absolute',
top: '0.5rem',
right: '0.5rem',
zIndex: 10,
}}
/>
<EditorContent editor={editor} />
</div>
</EditorContext.Provider>
)
}When an open SearchAndReplace panel changes to open={false}, the component stays mounted, the closed panel is no longer displayed, result highlights are removed, and the entered search and replacement values are kept. Opening it again reapplies the stored search. Unmounting the component clears the search and pending work entirely.
The panel does not close itself when the user clicks outside it. If your layout needs outside-click dismissal, manage that behavior in the component that owns open.
Navigating results moves the highlight only. The editor selection stays where the user left it, so toolbar state, floating menus, and popovers such as the link popover do not react to the current match.
Props
Two separate states control the panel: open decides whether a mounted panel is shown or closed, and hideWhenUnavailable decides whether the component renders at all.
The component also forwards HTMLAttributes<HTMLDivElement>—except children—to the root panel.
| Name | Type | Default | Description |
|---|---|---|---|
editor | Editor | null | Editor from EditorContext | The Tiptap editor instance. |
hideWhenUnavailable | boolean | false | Renders nothing when the Find and Replace extension is unavailable. Otherwise the panel still renders, with disabled controls. |
scrollIntoViewOptions | ScrollIntoViewOptions | { block: 'nearest', inline: 'nearest' } | Options used when the current result is scrolled into view. Smooth scrolling becomes immediate when the user prefers reduced motion. |
open | boolean | true | Whether the mounted panel is open. Closing preserves entered values but suspends result highlighting. |
onClose | () => void | undefined | Called by the close button or Escape. The callback must update the controlled open state. |
onOpen | () => void | undefined | Called when Mod+F is pressed while the panel is closed. Provide it when the shortcut should reopen the panel. |
enableShortcut | boolean | true | Binds Mod+F for the whole page while the component is mounted and the extension is available, including while the panel is closed. |
autoFocusSearch | boolean | true | Focuses and selects the search input when the panel opens and the editor is ready. |
className | string | undefined | Adds a class to the root panel without removing tiptap-search-replace. |
<SearchAndReplaceContent />
An exact alias of SearchAndReplace. Use this name when the panel is embedded as inline content, such as inside a collapsed mobile toolbar. It accepts the same SearchAndReplaceProps.
<SearchAndReplaceContent
editor={editor}
open={isSearchOpen}
onOpen={() => setIsSearchOpen(true)}
onClose={() => setIsSearchOpen(false)}
/><SearchAndReplaceButton />
A presentational trigger built on the shared Button primitive. It renders the search icon, tooltip, and Mod+F shortcut hint, but it does not manage panel state or run editor commands.
<SearchAndReplaceButton
tabIndex={0}
aria-expanded={isSearchOpen}
data-active-state={isSearchOpen ? 'on' : 'off'}
onClick={() => setIsSearchOpen((current) => !current)}
/>The button accepts the shared ButtonProps. Its default tabIndex is -1 because toolbar primitives manage focus with roving tab stops. Set tabIndex={0} when you render it as a standalone button outside a managed toolbar.
Regular expression mode
Turn on Use regular expression to treat the search term as a regular expression source. The extension compiles these patterns with an RE2-compatible engine, so lookarounds and backreferences are not supported. The match-case and whole-word options stay available while regex mode is active, and the panel adds buttons that fill the inputs with example patterns.
Invalid patterns, and patterns the engine rejects as unsafe, return zero results without throwing. Regular expressions affect matching only: replacement text is inserted literally, so capture references such as $1 are not expanded.
For extension-level options and matching rules, including how each option behaves in regex mode, see the Find and Replace extension documentation.
Hooks
useSearchAndReplace()
A headless hook exposing the same reactive editor state and actions used by the prebuilt panel.
Usage
import { useSearchAndReplace } from '@/components/tiptap-ui/search-and-replace'
function CustomSearchPanel({ editor }) {
const {
isVisible,
canSearch,
searchTerm,
replaceTerm,
resultCountLabel,
caseSensitive,
canNavigate,
canReplace,
setSearchTerm,
setReplaceTerm,
toggleCaseSensitive,
goToNext,
replaceCurrent,
} = useSearchAndReplace({
editor,
hideWhenUnavailable: true,
})
if (!isVisible) return null
return (
<div role="search">
<input
aria-label="Search"
value={searchTerm}
disabled={!canSearch}
onChange={(event) => setSearchTerm(event.target.value)}
/>
<input
aria-label="Replace"
value={replaceTerm}
disabled={!canSearch}
onChange={(event) => setReplaceTerm(event.target.value)}
/>
<span>{resultCountLabel}</span>
<button
type="button"
aria-pressed={caseSensitive}
disabled={!canSearch}
onClick={toggleCaseSensitive}
>
Match case
</button>
<button type="button" disabled={!canNavigate} onClick={goToNext}>
Next
</button>
<button type="button" disabled={!canReplace} onClick={replaceCurrent}>
Replace
</button>
</div>
)
}Configuration
| Name | Type | Default | Description |
|---|---|---|---|
editor | Editor | null | Editor from EditorContext | The Tiptap editor instance. |
hideWhenUnavailable | boolean | false | Whether the hook reports isVisible: false when the Find and Replace extension is unavailable. |
scrollIntoViewOptions | ScrollIntoViewOptions | DEFAULT_SCROLL_INTO_VIEW_OPTIONS | Options used to reveal the current result without moving focus. |
Return values
| Name | Type | Description |
|---|---|---|
editor | Editor | null | The resolved editor instance. |
isVisible | boolean | Whether the search UI should render. |
canSearch | boolean | Whether the editor has the Find and Replace extension. |
searchTerm | string | The immediate search input value. |
replaceTerm | string | The immediate replacement input value. |
appliedSearchTerm | string | The search term currently applied by the extension after its debounce. |
total | number | Number of current results. |
currentIndex | number | null | Zero-based current-result index, or null when no result is selected. |
resultCountLabel | string | One-based label such as 1 / 8, or 0 / 0 without results. |
caseSensitive | boolean | Current match-case state from extension storage. |
wholeWord | boolean | Current whole-word state from extension storage. |
useRegex | boolean | Current regular-expression state from extension storage. |
canNavigate | boolean | Whether at least one result can be selected. |
canReplace | boolean | Whether the editor is editable, a current result exists, and replacement text is non-empty. |
canReplaceAll | boolean | Whether the editor is editable, results exist, and replacement text is non-empty. |
setSearchTerm | (value: string) => void | Updates the input immediately and forwards non-empty values to the extension. Empty text clears results at once. |
setReplaceTerm | (value: string) => void | Updates the replacement value in the hook and extension. |
toggleCaseSensitive | () => void | Toggles case-sensitive matching. |
toggleWholeWord | () => void | Toggles whole-word matching. |
toggleUseRegex | () => void | Toggles regular-expression matching. |
goToNext | () => boolean | Selects the next result and wraps at the end. |
goToPrevious | () => boolean | Selects the previous result and wraps at the beginning. |
replaceCurrent | () => boolean | Replaces the current result and advances when possible. |
replaceAll | () => boolean | Replaces every result in the current result set. |
applySearch | () => void | Reapplies the stored search and replacement values, for example when reopening a mounted panel. |
suspendSearch | () => void | Clears highlights and pending work while preserving the hook's input values. |
clearSearch | () => void | Clears input values, pending work, and highlights. |
label | string | Accessible label: Search and replace. |
shortcutKeys | string | Main shortcut key: mod+f. |
Icon | React.ComponentType | Search icon component. |
The exported UseSearchAndReplaceReturn type is the inferred return type of this hook.
Utilities
Availability and storage helpers
import {
getFindAndReplaceStorage,
isFindAndReplaceAvailable,
shouldShowSearchAndReplace,
} from '@/components/tiptap-ui/search-and-replace'
const available = isFindAndReplaceAvailable(editor)
const storage = getFindAndReplaceStorage(editor)
const visible = shouldShowSearchAndReplace({
editor,
hideWhenUnavailable: true,
})isFindAndReplaceAvailable(editor)checks whether thefindAndReplaceextension is registered.getFindAndReplaceStorage(editor)returns its reactive storage object, ornull.shouldShowSearchAndReplace({ editor, hideWhenUnavailable })applies the component's visibility rule.
Result formatting and scrolling
import {
DEFAULT_SCROLL_INTO_VIEW_OPTIONS,
formatResultCount,
scrollCurrentResultIntoView,
} from '@/components/tiptap-ui/search-and-replace'
formatResultCount(null, 0) // "0 / 0"
formatResultCount(0, 8) // "1 / 8"
scrollCurrentResultIntoView(editor, {
block: 'center',
inline: 'nearest',
})scrollCurrentResultIntoView() resolves the current match from its document position and scrolls it without moving focus. It does nothing while focus is inside the editor and honors prefers-reduced-motion.
Exported constants
| Constant | Value | Purpose |
|---|---|---|
SEARCH_AND_REPLACE_SHORTCUT_KEY | mod+f | Open or refocus search. |
NEXT_RESULT_SHORTCUT_KEY | mod+shift+f | Select the next result from inside the panel. |
PREVIOUS_RESULT_SHORTCUT_KEY | mod+shift+d | Select the previous result from inside the panel. |
SEARCH_RESULT_CLASS | find-and-replace-result | Class rendered by the extension for every match. |
SEARCH_RESULT_CURRENT_CLASS | find-and-replace-result-current | Class rendered by the extension for the current match. |
DEFAULT_SCROLL_INTO_VIEW_OPTIONS | { block: 'nearest', inline: 'nearest' } | Default current-result scroll alignment. |
The module also exports SearchAndReplaceProps, SearchAndReplaceContentProps, UseSearchAndReplaceConfig, UseSearchAndReplaceReturn, and SearchAndReplaceEditorState.
Keyboard Shortcuts
| Keys | Behavior |
|---|---|
Mod+F | Focuses and selects the search input while open. While closed, calls onOpen when provided; otherwise native browser search remains available. |
ArrowDown | Selects the next result while search is open and results exist. |
ArrowUp | Selects the previous result while search is open and results exist. |
Enter in Search | Selects the next result. |
Enter in Replace | Replaces the current result when replacement is enabled. |
Mod+Shift+F | Selects the next result while focus is inside the panel. |
Mod+Shift+D | Selects the previous result while focus is inside the panel. |
Escape | Calls onClose while focus is inside the panel. |
Plain arrow-key navigation outside the panel does not take over editor content, form fields, or other text inputs.
While the component is mounted and the extension is available, whether the panel is open or closed, Mod+F is captured for the whole page—including other inputs and contenteditable areas—so the browser's find-in-page never competes with your own search. Set enableShortcut to false when a layout should leave Mod+F to the browser.
Styling and public selectors
The panel is unpositioned by default and has a width of 19rem with max-width: 100%. At viewports up to 480px, it can stretch to the available width. Add positioning through className, style, or an outer layout component.
These classes are public styling and query hooks:
tiptap-search-replacetiptap-search-replace-headertiptap-search-replace-counttiptap-search-replace-nav-grouptiptap-search-replace-inputstiptap-search-replace-optionstiptap-search-replace-actionsbutton-group
The component also exposes:
role="dialog"andaria-label="Search and replace"on the root panel, with the result counter announced througharia-live="polite".data-open="true|false"on the root panel.data-search-replace-action="prev|next|close|replace|replace-all|regex-docs"on actions and the regex documentation link.data-field="search-query|replace-query"on the inputs.data-option="match-case|whole-words|use-regex"on matching controls.data-regex-example="<pattern>"on each button that fills the fields with a regular expression example.
The result highlight selectors are .find-and-replace-result and .find-and-replace-result-current. If you override them, keep injectCSS: false on the extension so only one highlight stylesheet controls the result.
Requirements
Dependencies
@tiptap/react- Core Tiptap React integration@tiptap/extension-find-and-replace- Search state, matching, navigation, and replacement commands@tiptap/starter-kit- Baseline editor extensions used by the installed examplereact-hotkeys-hook- Keyboard shortcut handling
Optional Dependencies
sass- SCSS styling supportsass-embedded- Sass compiler used by projects that select the embedded implementation
Referenced Components
use-tiptap-editoranduse-composed-refhookstiptap-utilsbutton,button-group,card,input-group,separator, andswitchprimitivessearch-icon,arrow-right-icon,external-link-icon,chevron-up-icon,chevron-down-icon,close-icon,case-sensitive-icon, andwhole-word-iconstylesshared style foundation
The button primitive additionally installs the check-icon used by its check variant.
For a framework-neutral command and storage example, see the custom Find and Replace UI guide.