Mod+F and seeds the query from an expanded editor selection.Enter, Shift+Enter, or the arrow buttons.useFind for custom controls.Install FindKit with the rest of your editor plugins:
import { createEditor } from 'platejs/react';
import { FindKit } from '@/components/editor/find';
import { EditorKit } from '@/components/editor/plugins';
const editor = createEditor({
plugins: [...EditorKit, ...FindKit],
});import { createEditor } from 'platejs/react';
import { FindKit } from '@/components/editor/find';
import { EditorKit } from '@/components/editor/plugins';
const editor = createEditor({
plugins: [...EditorKit, ...FindKit],
});BaseFindPlugin owns the current query, matches, navigation and transient
decoration data. FindKit supplies the shortcut, deferred input and accessible
search bar. Its configured decorate.attributes supplies ordinary and active
match styling. Each mounted Editable owns its open bar, draft, focus and scroll
requests. Views of the same editor share committed queries and results; opening
one bar does not open another. Open bars follow the committed query when they
have no pending draft. Only the focused bar scrolls its view. Detaching a view
preserves the shared search. Search state stays outside the document and history.
'use client';
import { ChevronDown, ChevronUp, Search, X } from 'lucide-react';
import { NodeApi } from 'platejs';
import { isHotkey } from 'platejs/dom';
import { BaseFindPlugin } from 'platejs/find';
import {
type Editor,
toReactPlugin,
useEditor,
usePluginStore,
} from 'platejs/react';
import * as React from 'react';
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from '@/components/ui/input-group';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
const FindContext = React.createContext<{
close: () => void;
open: (query?: string) => void;
} | null>(null);
/** Commands for the Find UI in the current Editable's slots. */
export function useFind() {
const find = React.useContext(FindContext);
if (!find) throw new Error('useFind requires an Editable with FindKit');
return find;
}
const getSelectedText = (editor: Editor) => {
const selection = editor.read.selection();
if (!selection || editor.read.selection.isCollapsed()) return undefined;
return editor.read
.fragment({ at: selection })
.map((node) => NodeApi.string(node))
.join('\n');
};
function FindRoot({
children,
editableRef,
}: {
children: React.ReactNode;
editableRef: React.RefObject<HTMLDivElement | null>;
}) {
const editor = useEditor();
const [state, setState] = React.useState<{
draft: { query: string } | null;
} | null>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const commands = React.useMemo(
() => ({
close: () => {
setState(null);
editor.plugin(BaseFindPlugin).api.search('');
editor.api.dom.focus();
},
open: (query?: string) => {
setState({ draft: query === undefined ? null : { query } });
inputRef.current?.focus();
inputRef.current?.select();
},
}),
[editor]
);
React.useEffect(() => {
const element = editableRef.current;
if (!element) return undefined;
const onKeyDown = (event: KeyboardEvent) => {
if (
event.target !== element ||
event.isComposing ||
// oxlint-disable-next-line typescript/no-deprecated -- Safari can clear isComposing before the final IME key event.
event.keyCode === 229 ||
!isHotkey('mod+f', event)
) {
return;
}
event.preventDefault();
commands.open(getSelectedText(editor));
};
element.addEventListener('keydown', onKeyDown);
return () => element.removeEventListener('keydown', onKeyDown);
}, [commands, editableRef, editor]);
return (
<FindContext.Provider value={commands}>
{children}
{state && (
<FindBar draft={state.draft} inputRef={inputRef} setState={setState} />
)}
</FindContext.Provider>
);
}
function FindBar({
draft,
inputRef,
setState,
}: {
draft: { query: string } | null;
inputRef: React.RefObject<HTMLInputElement | null>;
setState: React.Dispatch<
React.SetStateAction<{ draft: { query: string } | null } | null>
>;
}) {
const editor = useEditor();
const { api } = editor.plugin(BaseFindPlugin);
const { close } = useFind();
const query = usePluginStore(BaseFindPlugin, 'query');
const count = usePluginStore(BaseFindPlugin, 'count');
const activeIndex = usePluginStore(BaseFindPlugin, 'activeIndex');
const error = usePluginStore(BaseFindPlugin, 'error');
const activeMatch = usePluginStore(BaseFindPlugin, 'activeMatch');
const deferredDraft = React.useDeferredValue(draft);
const [focused, setFocused] = React.useState(false);
const inputQuery = draft?.query ?? query;
React.useEffect(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, [inputRef]);
React.useEffect(() => {
if (!deferredDraft) return;
api.search(deferredDraft.query);
setState((current) =>
current?.draft === deferredDraft ? { draft: null } : current
);
}, [api, deferredDraft, setState]);
React.useEffect(() => {
if (!activeMatch || !focused) return undefined;
return editor.api.dom.scrollIntoView(activeMatch.range, {
block: 'nearest',
inline: 'nearest',
scrollMode: 'if-needed',
});
}, [activeMatch, editor, focused]);
const countLabel =
count === 0 ? 'No results' : `${activeIndex + 1} of ${count}`;
return (
<div
aria-busy={inputQuery !== query}
aria-label="Find in document"
className="absolute top-2 right-2 z-[60] w-[min(24rem,calc(100%-1rem))] rounded-xl border bg-background p-2 shadow-lg"
data-editor-keep-selection-visible=""
onBlurCapture={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
setFocused(false);
}
}}
onFocusCapture={() => setFocused(true)}
role="search"
>
<InputGroup>
<InputGroupAddon>
<Search aria-hidden data-icon="inline-start" />
</InputGroupAddon>
<InputGroupInput
ref={inputRef}
aria-label="Find text"
onChange={(event) =>
setState({ draft: { query: event.target.value } })
}
onKeyDown={(event) => {
// oxlint-disable-next-line typescript/no-deprecated -- Safari can clear isComposing before the final IME key event.
if (event.nativeEvent.isComposing || event.keyCode === 229) return;
if (event.key === 'Escape') {
event.preventDefault();
close();
return;
}
if (event.key !== 'Enter') return;
event.preventDefault();
if (event.shiftKey) {
api.move(-1);
} else {
api.move(1);
}
}}
placeholder="Find in document"
type="search"
value={inputQuery}
/>
<InputGroupAddon align="inline-end">
<span
aria-live="polite"
className="text-xs whitespace-nowrap text-muted-foreground"
>
{error?.message ?? countLabel}
</span>
<Tooltip>
<TooltipTrigger asChild>
<InputGroupButton
aria-label="Previous match"
disabled={count === 0}
onClick={() => api.move(-1)}
size="icon-xs"
>
<ChevronUp aria-hidden data-icon="" />
</InputGroupButton>
</TooltipTrigger>
<TooltipContent>Previous match</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<InputGroupButton
aria-label="Next match"
disabled={count === 0}
onClick={() => api.move(1)}
size="icon-xs"
>
<ChevronDown aria-hidden data-icon="" />
</InputGroupButton>
</TooltipTrigger>
<TooltipContent>Next match</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<InputGroupButton
aria-label="Close find"
onClick={close}
size="icon-xs"
>
<X aria-hidden data-icon="" />
</InputGroupButton>
</TooltipTrigger>
<TooltipContent>Close find</TooltipContent>
</Tooltip>
</InputGroupAddon>
</InputGroup>
</div>
);
}
export const FindKit = [
toReactPlugin(BaseFindPlugin).configure({
editOnly: { render: false },
decorate: {
attributes: {
className:
'rounded-[2px] bg-yellow-200 text-inherit data-find-active:bg-orange-400! data-find-active:ring-1 data-find-active:ring-orange-600',
},
},
slots: {
wrapRoot: FindRoot,
},
}),
] as const;'use client';
import { ChevronDown, ChevronUp, Search, X } from 'lucide-react';
import { NodeApi } from 'platejs';
import { isHotkey } from 'platejs/dom';
import { BaseFindPlugin } from 'platejs/find';
import {
type Editor,
toReactPlugin,
useEditor,
usePluginStore,
} from 'platejs/react';
import * as React from 'react';
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from
Use the headless feature for search without the copied bar:
import { createEditor } from 'platejs';
import { BaseFindPlugin } from 'platejs/find';
const editor = createEditor({ plugins: [BaseFindPlugin] });
const find = editor.plugin(BaseFindPlugin);
find.api.search('example');
find.api.move(1);
find.store.get('matches');
find.store.get('activeMatch');
find.update.select();import { createEditor } from 'platejs';
import { BaseFindPlugin } from 'platejs/find';
const editor = createEditor({ plugins: [BaseFindPlugin] });
const find = editor.plugin(BaseFindPlugin);
find.api.search('example');
find.api.move(1);
find.store.get('matches');
find.store.get('activeMatch');
find.update.select();api.search(query) applies a case-insensitive literal query. An empty query
clears the results. api.move(1 | -1) wraps through the current results without
rescanning. Document commits refresh the query automatically, including when
no view is mounted. update.select() selects the current range and returns
whether a result exists; view focus remains the caller's responsibility.
FindPluginState contains query, immutable matches, activeIndex and
error. The count and activeMatch store selectors derive current results.
Each FindMatch contains an ID and range. Match data is transient and never
stored as document marks. Custom renderers can style data-find-match and
data-find-active attributes.
Use useFind in a component rendered by that Editable's beforeEditable or
afterEditable slot. FindKit supplies the context through wrapRoot, so a
custom control addresses one mounted view without subscribing to search results:
import { useFind } from '@/components/editor/find';
export function FindButton() {
const { open } = useFind();
return (
<button onClick={() => open()} type="button">
Find
</button>
);
}import { useFind } from '@/components/editor/find';
export function FindButton() {
const { open } = useFind();
return (
<button onClick={() => open()} type="button">
Find
</button>
);
}In the copied FindKit configuration, add beforeEditable: FindButton alongside
wrapRoot: FindRoot. A sibling of <Editor> outside those slots does not have
the exact view's Find context.
open(query?) opens and focuses this view's bar. An explicit query becomes its
input; otherwise it uses the committed search query. close() closes this
view's bar, clears the shared query and returns focus to this view's editor.
Typing stays local until the bar applies its deferred query.
Search controls use editor.plugin(BaseFindPlugin) directly. Controls that
display results can subscribe with usePluginStore(BaseFindPlugin, 'count'),
'activeIndex', or 'error'. Opening controls do not need these subscriptions.
To select a result and focus the current view from an Editable slot, call the existing selection and DOM commands:
import { BaseFindPlugin } from 'platejs/find';
import { useEditor } from 'platejs/react';
const editor = useEditor();
const selectMatch = () => {
if (editor.plugin(BaseFindPlugin).update.select()) {
editor.api.dom.focus();
}
};import { BaseFindPlugin } from 'platejs/find';
import { useEditor } from 'platejs/react';
const editor = useEditor();
const selectMatch = () => {
if (editor.plugin(BaseFindPlugin).update.select()) {
editor.api.dom.focus();
}
};| Key | Behavior |
|---|---|
Mod+F | Open Find. An expanded selection becomes the initial query. |
Enter | Move to the next result. |
Shift+Enter | Move to the previous result. |
Escape | Close Find and return focus to the editor. |
Find searches text. Document replacement belongs to an app command with its own selection, history, and collaboration policy.