Plate
PlateEditorsTemplates
GitHub16kGitHub
DiscordDiscord
  • Introduction
  • Installation
    • Plate UI
      • Next.js
      • React
    • Manual
    • RSC
    • Node.js
    • Local Docs
    • MCP
  • Releases

Find

PreviousNext

Search editable text with transient highlights and keyboard navigation.

Find ComponentFind Demo
Loading…
Drag & DropMulti Select

On This Page

FeaturesInstallationUsageSearch APICustom controlsKeyboard behavior
Build your editor
Production-ready AI template and reusable components.
Get all-access

Features

  • Opens with Mod+F and seeds the query from an expanded editor selection.
  • Highlights every match without writing marks into the document or history.
  • Searches through adjacent text leaves and inline descendants.
  • Moves between results with Enter, Shift+Enter, or the arrow buttons.
  • Rescans after document commits and keeps one active result visible.
  • Opens independently in each mounted editor view, with useFind for custom controls.
Report an issue

Installation

pnpm dlx shadcn@latest add @plate/find
pnpm dlx shadcn@latest add @plate/find

Usage

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



























































































































































































































































Search API

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.

Custom controls

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();
  }
};

Keyboard behavior

KeyBehavior
Mod+FOpen Find. An expanded selection becomes the initial query.
EnterMove to the next result.
Shift+EnterMove to the previous result.
EscapeClose 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.

'@/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;