zudo-codemirror-wisdom
GitHub repository

Type to search...

to open search from anywhere

Search & Replace

Using @codemirror/search for find and replace in CodeMirror 6: search panel, keybindings, highlighting matches, and regular expression search.

The @codemirror/search package provides find-and-replace functionality for CodeMirror 6. It includes a search panel UI, keyboard shortcuts, match highlighting, and support for regular expressions.

To enable search, add the search() extension and the searchKeymap keybindings:

import { search, searchKeymap } from "@codemirror/search";
import { keymap } from "@codemirror/view";

const extensions = [
  search(),
  keymap.of(searchKeymap),
];

searchKeymap

The searchKeymap provides the following default bindings:

  • Ctrl-F (Windows/Linux) / Cmd-F (macOS) -- Open the search panel

  • Ctrl-H (Windows/Linux) / Cmd-Option-F (macOS) -- Open the search panel with replace

  • F3 / Ctrl-G -- Find next match

  • Shift-F3 / Ctrl-Shift-G -- Find previous match

  • Alt-G -- Go to line

highlightSelectionMatches()

This extension, also from @codemirror/search, highlights all occurrences of the currently selected text throughout the document. It works independently of the search panel.

import { highlightSelectionMatches } from "@codemirror/search";

const extensions = [highlightSelectionMatches()];

You can configure the minimum selection length and the styling:

highlightSelectionMatches({
  minSelectionLength: 2,
  highlightWordAroundCursor: true,
})

When highlightWordAroundCursor is true, placing the cursor inside a word (without selecting) will highlight all occurrences of that word.

Programmatic Search Commands

The package exports several commands for controlling search from code:

import {
  openSearchPanel,
  closeSearchPanel,
  findNext,
  findPrevious,
  replaceNext,
  replaceAll,
  selectMatches,
} from "@codemirror/search";

openSearchPanel and closeSearchPanel

These commands open and close the search panel programmatically:

import { openSearchPanel, closeSearchPanel } from "@codemirror/search";

// Open the search panel
openSearchPanel(view);

// Close the search panel
closeSearchPanel(view);

findNext and findPrevious

Move the selection to the next or previous match:

import { findNext, findPrevious } from "@codemirror/search";

findNext(view);
findPrevious(view);

replaceNext and replaceAll

Replace the current match or all matches:

import { replaceNext, replaceAll } from "@codemirror/search";

replaceNext(view);
replaceAll(view);

selectMatches

Selects all matches simultaneously, creating multiple selections:

import { selectMatches } from "@codemirror/search";

selectMatches(view);

Search Configuration

The search() function accepts a configuration object:

import { search } from "@codemirror/search";

const extensions = [
  search({
    top: true,  // Show the search panel at the top of the editor (default: false)
  }),
];

SearchQuery

You can set the initial search state programmatically using SearchQuery and the setSearchQuery effect:

import { SearchQuery, setSearchQuery } from "@codemirror/search";

const query = new SearchQuery({
  search: "hello",
  caseSensitive: false,
  regexp: false,
  replace: "world",
});

view.dispatch({
  effects: setSearchQuery.of(query),
});

The search panel includes a toggle button for regular expression mode. When enabled, the search string is interpreted as a regular expression.

You can also configure regex search programmatically through SearchQuery:

import { SearchQuery, setSearchQuery } from "@codemirror/search";

const query = new SearchQuery({
  search: "\\bfunction\\s+\\w+",
  regexp: true,
});

view.dispatch({
  effects: setSearchQuery.of(query),
});

Regular expression search supports JavaScript regex syntax. Capture groups can be referenced in the replace string using $1, $2, etc.

const query = new SearchQuery({
  search: "(\\w+)\\s*=\\s*(\\w+)",
  regexp: true,
  replace: "$2 = $1",  // Swap the two sides of the assignment
});

Styling the Search Panel

The search panel renders with CSS classes that you can style in your theme:

import { EditorView } from "@codemirror/view";

const searchTheme = EditorView.theme({
  ".cm-panels": {
    backgroundColor: "#f5f5f5",
    borderBottom: "1px solid #ddd",
  },
  ".cm-searchMatch": {
    backgroundColor: "#ffd54f80",
  },
  ".cm-searchMatch-selected": {
    backgroundColor: "#ff980080",
  },
});
  • .cm-panels -- the container for the search panel

  • .cm-searchMatch -- highlighted search matches in the document

  • .cm-searchMatch-selected -- the currently selected match

Complete Search Setup

A full example with search, selection highlighting, and keybindings:

import { EditorState } from "@codemirror/state";
import { EditorView, keymap } from "@codemirror/view";
import {
  search,
  searchKeymap,
  highlightSelectionMatches,
} from "@codemirror/search";

const state = EditorState.create({
  doc: "Search for text in this editor.",
  extensions: [
    search({ top: true }),
    highlightSelectionMatches(),
    keymap.of(searchKeymap),
  ],
});

const view = new EditorView({
  state,
  parent: document.getElementById("editor"),
});

Try pressing Ctrl-F (or Cmd-F on macOS) to open the search panel in the editor below. The text contains repeated words you can search for:

Search & Replace Demo

Custom highlight layer

Sometimes search logic lives outside CodeMirror — for example, when a find bar is shared between an editor pane and a preview pane, or when a custom matcher (fuzzy search, AST-aware search) runs independently and hands back document offsets. In those cases you do not want @codemirror/search at all; you want a thin decoration layer you can push pre-computed ranges into.

The pattern is a StateField<DecorationSet> combined with a single StateEffect that replaces the entire highlight set in one transaction. The field's update() function handles two separate concerns:

  1. Mapping through edits — when the user types, tr.changes remaps the existing decorations to their new positions so highlights stay glued to the text between search dispatches.

  2. Replacing on effect — when your orchestrator has new results, it dispatches a setSearchHighlightsEffect with the full list of ranges and the field swaps the whole set wholesale.

Defining the effect and mark decorations

import { StateEffect } from "@codemirror/state";
import { Decoration } from "@codemirror/view";

export interface SearchHighlightRange {
  from: number;
  to: number;
  active: boolean; // true => the navigation target (one per dispatch)
}

// Single-shot replace: the whole set is swapped each time.
// No incremental "add one hit" effect — the caller owns the canonical list.
export const setSearchHighlightsEffect =
  StateEffect.define<SearchHighlightRange[]>();

// Two-tier marks: active hit gets a distinct class for styling.
const findMatchDeco = Decoration.mark({ class: "find-match" });
const findMatchActiveDeco = Decoration.mark({ class: "find-match-active" });

Building the decoration set defensively

Decoration.set requires its input to be sorted by from position and rejects zero-length or inverted ranges. Sort and filter defensively so callers cannot accidentally violate the invariant:

import { Decoration } from "@codemirror/view";
import type { DecorationSet } from "@codemirror/view";

function buildDecorations(
  ranges: readonly SearchHighlightRange[],
  docLength: number,
): DecorationSet {
  if (ranges.length === 0) return Decoration.none;

  const sorted = ranges
    // Drop zero-length ranges (Decoration.mark requires from < to) and
    // out-of-bounds ranges (defensive guard for upstream regex edge cases).
    .filter((r) => r.from < r.to && r.from >= 0 && r.to <= docLength)
    .slice()
    .sort((a, b) => a.from - b.from || a.to - b.to);

  if (sorted.length === 0) return Decoration.none;

  return Decoration.set(
    sorted.map((r) =>
      (r.active ? findMatchActiveDeco : findMatchDeco).range(r.from, r.to),
    ),
  );
}

The state field

The update() function maps existing decorations through tr.changes before checking for the effect. This ordering matters: if the effect carries new ranges, they are validated against the post-transaction document length (after the edit has already been applied).

import { StateField } from "@codemirror/state";
import { Decoration, EditorView } from "@codemirror/view";
import type { DecorationSet } from "@codemirror/view";

export const searchHighlightField = StateField.define<DecorationSet>({
  create: () => Decoration.none,

  update(value, tr) {
    let next = value;

    // Step 1: remap through edits so marks stay glued to text between dispatches.
    // Without this, a character insertion before a highlight would leave the mark
    // pointing at the wrong offset until the next search dispatch.
    if (tr.docChanged) {
      next = next.map(tr.changes);
    }

    // Step 2: if new ranges arrived, replace the entire set.
    for (const e of tr.effects) {
      if (e.is(setSearchHighlightsEffect)) {
        next = buildDecorations(e.value, tr.state.doc.length);
      }
    }

    return next;
  },

  // Wire the DecorationSet into the view's decoration layer.
  provide: (field) => EditorView.decorations.from(field),
});

Pushing ranges from outside CodeMirror

Register the field as an extension and push ranges via view.dispatch:

import { EditorView, basicSetup } from "codemirror";

const view = new EditorView({
  doc: "The quick brown fox jumps over the lazy dog.",
  extensions: [basicSetup, searchHighlightField],
  parent: document.getElementById("editor"),
});

// Called by your external search orchestrator whenever hits change.
function applyHighlights(ranges: SearchHighlightRange[]) {
  view.dispatch({
    effects: setSearchHighlightsEffect.of(ranges),
  });
}

// Example: highlight "fox" (basic) and "dog" (active / navigation target).
applyHighlights([
  { from: 16, to: 19, active: false }, // "fox"
  { from: 40, to: 43, active: true },  // "dog" — the active navigation hit
]);

// Clear all highlights by dispatching an empty array.
applyHighlights([]);

CSS for the two tiers

The mark classes need styling. Add them to your editor's stylesheet or EditorView.theme:

import { EditorView } from "@codemirror/view";

const highlightTheme = EditorView.theme({
  ".find-match": {
    backgroundColor: "#ffd54f80",
    borderRadius: "2px",
  },
  ".find-match-active": {
    backgroundColor: "#ff980080",
    outline: "1px solid #e65100",
    borderRadius: "2px",
  },
});

Why not @codemirror/search?

@codemirror/search owns its own search state and runs its own query inside CM. If you have a custom matcher or need a single search to span multiple panes, a thin custom field like this is the right fit: it does nothing except hold ranges and remap them, and your orchestrator remains the sole source of truth for what matches exist.

Revision History

Takeshi TakatsudoCreated: 2026-03-30T04:51:29+09:00Updated: 2026-05-29T05:51:11+09:00