Text Highlighting

Show users exactly where matches occurred with character-level highlight ranges.

How Highlights Work

Every search result includes a highlights array with character-level ranges for each matched field:

const results = search.search('javascript patterns')

console.log(results[0].highlights)
// [
//   {
//     field: 'title',
//     value: 'JavaScript Design Patterns',
//     indices: [[0, 10], [18, 26]]
//   }
// ]

// indices: [[0, 10], [18, 26]] means:
// - Characters 0-10: "JavaScript"
// - Characters 18-26: "Patterns"

Basic HTML Highlighting

function highlightText(value, indices) {
  if (!indices || indices.length === 0) {
    return value
  }

  let result = ''
  let lastIndex = 0

  indices.forEach(([start, end]) => {
    // Text before match
    result += value.slice(lastIndex, start)
    // Matched text
    result += '<mark>' + value.slice(start, end) + '</mark>'
    lastIndex = end
  })

  // Text after last match
  result += value.slice(lastIndex)

  return result
}

// Usage
const highlight = results[0].highlights[0]
const html = highlightText(highlight.value, highlight.indices)
// => "JavaScript Design <mark>Patterns</mark>"

React Component

function Highlight({ value, indices }) {
  if (!indices || indices.length === 0) {
    return <span>{value}</span>
  }

  const parts = []
  let lastIndex = 0

  indices.forEach(([start, end], i) => {
    // Text before match
    if (start > lastIndex) {
      parts.push(
        <span key={`text-${i}`}>
          {value.slice(lastIndex, start)}
        </span>
      )
    }

    // Matched text
    parts.push(
      <mark key={`mark-${i}`}>
        {value.slice(start, end)}
      </mark>
    )

    lastIndex = end
  })

  // Text after last match
  if (lastIndex < value.length) {
    parts.push(
      <span key="text-end">
        {value.slice(lastIndex)}
      </span>
    )
  }

  return <span>{parts}</span>
}

// Usage
function SearchResult({ result }) {
  return (
    <div>
      {result.highlights.map((h, i) => (
        <div key={i}>
          <strong>{h.field}:</strong>
          <Highlight value={h.value} indices={h.indices} />
        </div>
      ))}
    </div>
  )
}

Custom Styling

// CSS
mark {
  background-color: #fef08a;
  font-weight: bold;
  padding: 2px 4px;
  border-radius: 2px;
}

// Or with Tailwind
function Highlight({ value, indices }) {
  // ... same logic as above, but:
  parts.push(
    <span
      key={`mark-${i}`}
      className="bg-yellow-200 font-semibold px-1 rounded"
    >
      {value.slice(start, end)}
    </span>
  )
}

Multiple Field Highlights

function SearchResult({ result }) {
  return (
    <div className="search-result">
      {result.highlights.map((highlight, index) => (
        <div key={index}>
          <div className="field-label">{highlight.field}</div>
          <Highlight
            value={highlight.value}
            indices={highlight.indices}
          />
        </div>
      ))}

      <div className="score">
        Score: {(result.score * 100).toFixed(0)}%
      </div>
    </div>
  )
}

Truncated Snippets

For long text fields, show only the matched portions:

function createSnippet(value, indices, contextLength = 40) {
  if (!indices || indices.length === 0) {
    return value.slice(0, 100) + '...'
  }

  // Use first match
  const [start, end] = indices[0]

  // Calculate snippet boundaries
  const snippetStart = Math.max(0, start - contextLength)
  const snippetEnd = Math.min(value.length, end + contextLength)

  let snippet = value.slice(snippetStart, snippetEnd)

  // Add ellipsis
  if (snippetStart > 0) snippet = '...' + snippet
  if (snippetEnd < value.length) snippet = snippet + '...'

  // Adjust indices for truncated text
  const adjustedIndices = indices
    .filter(([s, e]) => s >= snippetStart && e <= snippetEnd)
    .map(([s, e]) => [
      s - snippetStart + (snippetStart > 0 ? 3 : 0),
      e - snippetStart + (snippetStart > 0 ? 3 : 0)
    ])

  return { snippet, indices: adjustedIndices }
}

// Usage
const { snippet, indices } = createSnippet(
  highlight.value,
  highlight.indices,
  50
)

<Highlight value={snippet} indices={indices} />

Multiple Snippets

Show multiple match locations in long documents:

function createSnippets(value, indices, contextLength = 40, maxSnippets = 3) {
  const snippets = []

  for (let i = 0; i < Math.min(indices.length, maxSnippets); i++) {
    const [start, end] = indices[i]

    const snippetStart = Math.max(0, start - contextLength)
    const snippetEnd = Math.min(value.length, end + contextLength)

    let snippet = value.slice(snippetStart, snippetEnd)

    if (snippetStart > 0) snippet = '...' + snippet
    if (snippetEnd < value.length) snippet = snippet + '...'

    // Find indices within this snippet
    const snippetIndices = indices
      .filter(([s, e]) => s >= snippetStart && e <= snippetEnd)
      .map(([s, e]) => [
        s - snippetStart + (snippetStart > 0 ? 3 : 0),
        e - snippetStart + (snippetStart > 0 ? 3 : 0)
      ])

    snippets.push({ snippet, indices: snippetIndices })
  }

  return snippets
}

// Usage
const snippets = createSnippets(highlight.value, highlight.indices)

snippets.map((s, i) => (
  <div key={i}>
    <Highlight value={s.snippet} indices={s.indices} />
  </div>
))

Case-Insensitive Highlighting

LumoSearch automatically handles case-insensitive matching:

// Query: "javascript"
// Document: "JavaScript Patterns"

// Highlights will show:
{
  value: "JavaScript Patterns",
  indices: [[0, 10]]  // Correctly highlights "JavaScript"
}

// The original casing is preserved

Accessibility

Make highlights accessible to screen readers:

function Highlight({ value, indices }) {
  if (!indices || indices.length === 0) {
    return <span>{value}</span>
  }

  const parts = []
  let lastIndex = 0

  indices.forEach(([start, end], i) => {
    if (start > lastIndex) {
      parts.push(
        <span key={`text-${i}`}>
          {value.slice(lastIndex, start)}
        </span>
      )
    }

    parts.push(
      <mark
        key={`mark-${i}`}
        aria-label="search match"
      >
        {value.slice(start, end)}
      </mark>
    )

    lastIndex = end
  })

  if (lastIndex < value.length) {
    parts.push(
      <span key="text-end">
        {value.slice(lastIndex)}
      </span>
    )
  }

  return <span role="text">{parts}</span>
}

Full Example

import { useState } from 'react'
import { LumoSearch } from '@lumosearch/search'

function SearchWithHighlights({ docs }) {
  const [query, setQuery] = useState('')

  const search = new LumoSearch(docs, {
    keys: [
      { name: 'title', weight: 3 },
      { name: 'body', weight: 1 }
    ]
  })

  const results = query.length > 0
    ? search.search(query, { limit: 10 })
    : []

  return (
    <div>
      <input
        type="search"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search..."
      />

      <div className="results">
        {results.map((result) => (
          <div key={result.refIndex} className="result-card">
            {result.highlights.map((highlight, i) => (
              <div key={i} className="highlight-field">
                <div className="field-name">
                  {highlight.field}
                </div>
                <Highlight
                  value={highlight.value}
                  indices={highlight.indices}
                />
              </div>
            ))}

            <div className="score">
              {(result.score * 100).toFixed(0)}% match
            </div>
          </div>
        ))}
      </div>
    </div>
  )
}

function Highlight({ value, indices }) {
  if (!indices || indices.length === 0) {
    return <span>{value}</span>
  }

  const parts = []
  let lastIndex = 0

  indices.forEach(([start, end], i) => {
    if (start > lastIndex) {
      parts.push(
        <span key={`text-${i}`}>
          {value.slice(lastIndex, start)}
        </span>
      )
    }

    parts.push(
      <mark
        key={`mark-${i}`}
        className="bg-yellow-200 font-semibold px-1 rounded"
      >
        {value.slice(start, end)}
      </mark>
    )

    lastIndex = end
  })

  if (lastIndex < value.length) {
    parts.push(
      <span key="text-end">
        {value.slice(lastIndex)}
      </span>
    )
  }

  return <span>{parts}</span>
}

Best Practices

  • Always check if indices exist before highlighting
  • Use semantic HTML (<mark>) for highlights
  • Provide sufficient contrast for highlighted text
  • Consider truncating long text fields to show only relevant snippets
  • Test with screen readers for accessibility
  • Use CSS to make highlights visually distinct but not overwhelming

Related