Text with the words of a search marked, as search results show them: real mark elements in a theme tint that stays readable in light and dark, plus helpers that split a query and test whether text matches it.
Verify webhook signatures
Every webhook request is signed. Check the signature header against your signing secret before you trust the payload.
import { MatchHighlight } from "@/components/ballmac/match-highlight"
export default function MatchHighlightDemo() {
return (
<div className="grid max-w-md gap-1.5">
<p className="text-base font-medium">
<MatchHighlight text="Verify webhook signatures" query="webhook sign" />
</p>
<p className="text-muted-foreground text-sm text-pretty">
<MatchHighlight text="Every webhook request is signed. Check the signature header against your signing secret before you trust the payload." query="webhook sign" />
</p>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/match-highlightCopy the source into your project.
components/ballmac/match-highlight.tsx// Ballmac UI: Match Highlight. https://ui.ballmac.com/components/match-highlight import * as React from "react" import { cn } from "@/lib/utils" const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") /** The words of a search, lowercased, without blanks or repeats. */ function queryTokens(query: string | string[]): string[] { const words = Array.isArray(query) ? query : query.split(/\s+/) return [...new Set(words.map((w) => w.trim().toLowerCase()).filter(Boolean))] } /** Whether every word of the search occurs in the text, ignoring case. An empty search matches everything. */ function matchesAll(text: string, query: string | string[]) { const lower = text.toLowerCase() return queryTokens(query).every((t) => lower.includes(t)) } /** How many times any word of the search occurs in the text, ignoring case. */ function countMatches(text: string, query: string | string[]) { const tokens = queryTokens(query) if (!tokens.length) return 0 return (text.match(new RegExp(tokens.map(escapeRegExp).join("|"), "gi")) ?? []).length } type MatchHighlightProps = Omit<React.ComponentProps<"span">, "children"> & { /** The text to show. */ text: string /** What was searched for: a string (split into words) or a list of words. Every occurrence of any word is marked. */ query?: string | string[] } /** * Text with the words of a search marked, as search results show them. It uses the real <mark> element, so screen readers can * find the matches, and a tint of a theme color that keeps the text readable in light and dark. Runs on the server. */ function MatchHighlight({ text, query = "", className, ...props }: MatchHighlightProps) { const tokens = queryTokens(query) if (!tokens.length) return <span data-slot="match-highlight" className={className} {...props}>{text}</span> const parts = text.split(new RegExp(`(${tokens.map(escapeRegExp).join("|")})`, "gi")) return ( <span data-slot="match-highlight" className={className} {...props}> {parts.map((part, i) => i % 2 ? ( <mark key={i} className={cn("bg-chart-3/35 text-foreground rounded-sm")}> {part} </mark> ) : ( part ) )} </span> ) } export { MatchHighlight, queryTokens, matchesAll, countMatches, type MatchHighlightProps }Update the import paths to match your project setup.
Usage
import { MatchHighlight, queryTokens, matchesAll, countMatches } from "@/components/ballmac/match-highlight"The full example is in the Code tab above.
Examples
Type to search
- Download invoices and receipts
- Invite teammates and set their role
- Turn on two-step sign-in
"use client"
import * as React from "react"
import { MatchHighlight, matchesAll } from "@/components/ballmac/match-highlight"
const items = ["Reset your password", "Download invoices and receipts", "Create and rotate an API key", "Invite teammates and set their role", "Turn on two-step sign-in", "Connect your chat workspace"]
export default function MatchHighlightLive() {
const [query, setQuery] = React.useState("in")
const shown = items.filter((i) => matchesAll(i, query))
return (
<div className="grid w-full max-w-sm gap-3">
<label className="grid gap-1.5 text-sm font-medium">
Filter
<input value={query} onChange={(e) => setQuery(e.target.value)} className="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/50 h-9 rounded-md border px-3 text-sm font-normal outline-none focus-visible:ring-[3px]" />
</label>
<ul className="grid gap-1 text-sm">
{shown.map((i) => (
<li key={i} className="rounded-md border px-3 py-2">
<MatchHighlight text={i} query={query} />
</li>
))}
{!shown.length ? <li className="text-muted-foreground px-1 text-sm">Nothing matches.</li> : null}
</ul>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
text*The text to show. | string | — |
queryWhat was searched for: a string (split into words) or a list of words. Every occurrence of any word is marked. | string | string[] | "" |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| None | Uses <mark>, which assistive technology can announce; the tint is chosen so the text keeps its contrast. |
Quality scorecard
Every applicable check passes
Measured in Chromium 153.0.8010.12 on October 19, 2026, on version 1.0.0, across 2 examples.
Passed: Accessibility scan
No serious or critical issues in light and dark, on a phone and a desktop (axe-core 4.13.0, WCAG 2.1 AA and best-practice rules).
Passed: Small screens
No sideways scrolling at 360, 390 or 1280 px wide.
Passed: Reduced motion
No endless animation to begin with.
Not applicable: Right to left
Not mirrored by design: The marks sit inside running text, whose word order follows the language of the text, so a mirror image of English prose cannot apply. It marks Arabic and Hebrew text correctly (tested).
Passed: Clean console
No console errors or React hydration errors while loading.
Passed: Keyboard
1 tab stop, each showing a visible change when it takes focus. 1 documented key.
Not applicable: Loading, empty and error
A display of text: nothing to load, submit or fail.
Passed: Automated tests
Covered by match-highlight.test.tsx.
For information: Footprint
1.0 KB of source (gzip) in 1 file. No extra npm packages.
Automated checks find a subset of problems and do not replace testing with a screen reader and a keyboard in your own app. What they miss.
Use with AI
<MatchHighlight text={result.title} query={query} /> marks every occurrence of every word of the query, ignoring case. queryTokens(query) gives the words, matchesAll(text, query) tests that all of them occur, countMatches(text, query) counts occurrences for ranking. No "use client": it works in server components. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Match Highlight (@ballmac/match-highlight) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Search results, suggestions and autocomplete rows
- Showing why an item matched a filter
Not for
- Syntax highlighting of code (use code-block)
- A hand-drawn emphasis mark on a phrase (use highlighter)
Registry JSON: https://ui.ballmac.com/r/match-highlight.json
Credits
Free to use in personal and commercial projects.
- npm
- None
- Registry
- shadcn/utils
Pairs well with
Search Field
A compact search control with a clear action, Enter callback, and preserved keyboard focus.
Command
A filterable command list on cmdk, inline or in a Cmd/Ctrl+K dialog, with groups, shortcuts, descriptions, loading state and page-safe scrolling.
Help 1: help center home
A help center home: a hero search that suggests articles as you type with highlighted matches, topic cards that light up under the pointer, popular articles, and ways to reach a person with who is online.
Search 1: results page
A search results page: a large search box, the type of result as a filter with live counts, a date filter and sort, results with a trail and highlighted words, and a no-results page that suggests what to try.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.