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 preservedAccessibility
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