← home

Svelte Action to Highlight Matching Text

I like the Svelte action API for how neatly it packages up modular functionality, like a power-up to HTML that nonetheless feels native, perhaps because it keeps your markup clean. The use case to highlight text matching a search query string comes up a lot so I’d like to share this highlight_matches action as an especially neat example of how to combine Svelte’s minimal syntax with the GPU-accelerated power of the CSS Custom Highlight API (supported by all major browsers and easily scales to very long texts) to achieve a performant drop-in solution for highlighting text matches.

Demo

Here’s a simple Svelte component that uses this action:

Type a query:

Lorem ipsum dolor sit amet consectetur adipisicing elit. Facilis nihil excepturi in earum reiciendis perferendis eveniet eum repudiandae! Assumenda dolorem numquam ullam cum vel ad voluptates voluptatum corporis id vero. Reprehenderit quibusdam, incidunt natus sequi officiis perferendis ullam est ea, sed officia dolores similique consequatur reiciendis, voluptatibus vero. Assumenda vero repellendus sit, id possimus consequatur accusantium sint minus voluptatum exercitationem. Repudiandae consectetur, eius odio esse perspiciatis dolores magnam quia et animi reiciendis, aperiam id a delectus, beatae porro voluptates commodi nesciunt praesentium modi molestiae maiores deleniti? Ab veritatis ducimus veniam. Ut, dolore eaque, animi quo unde ipsam praesentium veritatis nemo voluptate itaque laboriosam ullam vel fugiat explicabo. Tempora corporis, voluptas itaque, et soluta odio expedita natus tempore quis eveniet ipsum.
<script>
  import { highlight_matches } from '$actions'

  let query = 'adipisicing'
  // must match the name passed to ::highlight() in below, defaults to 'highlight-match'
  const css_class = 'highlight-match'
</script>

Type a query: <input bind:value={query} />
<p use:highlight_matches={{ query, css_class }}>
  Lorem ipsum dolor sit amet consectetur adipisicing elit. Facilis nihil excepturi in earum reiciendis perferendis eveniet eum repudiandae! Assumenda dolorem numquam ullam cum vel ad voluptates voluptatum corporis id vero...
</p>

<style>
  ::highlight(highlight-match) {
    color: mediumaquamarine;
    text-decoration: underline;
  }
</style>

Any text context within the DOM node to which we pass use:highlight_matches (in this case <p>) will be highlighted when matching query. The query can be bound to an <input> or Svelte store and will be fully reactive. Any matching text will get the CSS pseudo-class ::highlight(highlight-match) and the corresponding style rules. Note that the allowed properties for ::highlight() are very limited (but sufficient for highlighting text).

Implementation

Like any Svelte action, highlight_matches takes an HTMLElement and an options object as parameters. The options object can include a query string, a disabled boolean, a node_filter function, and a css_class string (all optional, though of course, the action won’t do anything if query is empty or disabled is true).

The update_highlights function is where the actual highlighting happens. It first clears any previous highlights. If the query is empty, disabled is true, or the CSS highlight API is not supported, it returns early.

Otherwise, we create a TreeWalker to iterate over all text nodes in the DOM subtree rooted at the given node. It finds matches of the query in each text node and creates a Range object for each match. Finally, it creates a Highlight object from the ranges and adds it to the CSS highlight registry.

Note that the action returns an object with an update which is needed to make it responsive to changes in the query string. Without it, the action would only run once on mount.

type HighlightOptions = {
  query?: string
  disabled?: boolean
  node_filter?: (node: Node) => number
  css_class?: string
}

export function highlight_matches(node: HTMLElement, ops: HighlightOptions) {
  update_highlights(node, ops)
  return {
    update: (ops: HighlightOptions) => update_highlights(node, ops),
  }
}

function update_highlights(node: Node, ops: HighlightOptions) {
  const {
    query = ``,
    disabled = false,
    node_filter = () => NodeFilter.FILTER_ACCEPT,
    css_class = `highlight-match`,
  } = ops

  // clear previous ranges from HighlightRegistry
  CSS.highlights.clear()

  if (!query || disabled || typeof CSS == `undefined` || !CSS.highlights) return // abort if CSS highlight API not supported

  const tree_walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, {
    acceptNode: node_filter,
  })
  const text_nodes: Node[] = []
  let current_node = tree_walker.nextNode()
  while (current_node) {
    text_nodes.push(current_node)
    current_node = tree_walker.nextNode()
  }

  // iterate over all text nodes and find matches
  const ranges = text_nodes.map((el) => {
    const text = el.textContent?.toLowerCase()
    const indices = []
    let start_pos = 0
    while (text && start_pos < text.length) {
      const index = text.indexOf(query, start_pos)
      if (index === -1) break
      indices.push(index)
      start_pos = index + query.length
    }

    // create range object for each str found in the text node
    return indices.map((index) => {
      const range = new Range()
      range.setStart(el, index)
      range.setEnd(el, index + query?.length)
      return range
    })
  })

  // create Highlight object from ranges and add to registry
  CSS.highlights.set(css_class, new Highlight(...ranges.flat()))
}

I started using this in several of my projects and so added this action to a utilities library called svelte-zoo. If you prefer not to copy-paste the code above, you can npm install svelte-zoo and use it like this:

<script>
  import { highlight_matches } from 'svelte-zoo'

  let query = 'quick'
</script>

<p use:highlight_matches={{ query, css_class }}>
  The quick brown fox jumps over the lazy dog
</p>