Highlight
Plain text with matched character ranges marked in native <mark> elements, from server offsets or a query; every part is escaped.
Preview
{{-- Search results with their matched ranges marked. The title takes ranges
that the server search returned; the snippet marks the words of the
query. Every part is printed escaped. --}}
<div class="flex w-full max-w-md flex-col gap-4">
<x-ui.item variant="outline">
<x-ui.item.content>
<x-ui.item.title>
<x-ui.highlight :text="__('Atlas launch plan')" :ranges="[[0, 5]]" variant="fill" />
</x-ui.item.title>
<x-ui.item.description>
<x-ui.highlight :text="__('Draft the launch checklist for Atlas and share it with the client.')" :query="__('atlas launch')" />
</x-ui.item.description>
</x-ui.item.content>
</x-ui.item>
<x-ui.item variant="outline">
<x-ui.item.content>
<x-ui.item.title>
<x-ui.highlight :text="__('Invoice numbering')" :query="__('atlas launch')" variant="fill" />
</x-ui.item.title>
<x-ui.item.description>
<x-ui.highlight :text="__('No word of the query is in this text, so nothing is marked.')" :query="__('atlas launch')" />
</x-ui.item.description>
</x-ui.item.content>
</x-ui.item>
</div>
Installation
php artisan ui:add highlight
Registry contract
php artisan ui:add highlight
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/highlight.blade.php
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Highlight (`highlight`)
Plain text with matched character ranges marked in native <mark> elements, from server offsets or a query; every part is escaped.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add highlight
```
## Usage
```blade
{{-- Search results with their matched ranges marked. The title takes ranges
that the server search returned; the snippet marks the words of the
query. Every part is printed escaped. --}}
<div class="flex w-full max-w-md flex-col gap-4">
<x-ui.item variant="outline">
<x-ui.item.content>
<x-ui.item.title>
<x-ui.highlight :text="__('Atlas launch plan')" :ranges="[[0, 5]]" variant="fill" />
</x-ui.item.title>
<x-ui.item.description>
<x-ui.highlight :text="__('Draft the launch checklist for Atlas and share it with the client.')" :query="__('atlas launch')" />
</x-ui.item.description>
</x-ui.item.content>
</x-ui.item>
<x-ui.item variant="outline">
<x-ui.item.content>
<x-ui.item.title>
<x-ui.highlight :text="__('Invoice numbering')" :query="__('atlas launch')" variant="fill" />
</x-ui.item.title>
<x-ui.item.description>
<x-ui.highlight :text="__('No word of the query is in this text, so nothing is marked.')" :query="__('atlas launch')" />
</x-ui.item.description>
</x-ui.item.content>
</x-ui.item>
</div>
```
## Props
- `text` (string, default ``) — The plain text to print. It is always escaped, so markup in user data shows as text.
- `ranges` (array, default `[]`) — Matched ranges as [start, end] character offsets (end exclusive) or ['start' => …, 'end' => …]. Overlapping and touching ranges merge; out-of-range offsets are clamped.
- `query` (string|null, default `null`) — Marks every case-insensitive occurrence of each word of the query, in addition to ranges.
- `variant` (weight|fill, default `weight`) — weight turns the match into semibold foreground text and reads on any row background (also an active option); fill adds a quiet muted background for a results page.
## Use when
- Use to summarize, sequence, or present data so users can scan it quickly.
- A search result title or snippet shows which characters matched the query.
- The server returns match offsets, or the typed query is known when the text renders.
## Avoid when
- Do not add display-only ornament when the user needs actionable structure or exact comparison instead.
- The text needs emphasis that is not a match; use strong or em, or prose for a formatted passage.
- The text is rendered in the browser from client data; mark it there with text nodes, never with innerHTML.
## Anti-patterns
- Adding display ornament without informational value
## Rules
- Use the `<brok:highlight>` tag (or `<x-ui.highlight>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/highlight
- Registry JSON (files, props, contract): https://brokui.dev/r/open/highlight.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
Long Content
<div class="max-w-xs">
<p class="text-sm text-muted-foreground">
<x-ui.highlight
:text="__('A deliberately long search snippet that wraps over several lines, so the marked words on each line keep their background and weight without clipping: onboarding, onboarding checklist, onboarding.')"
:query="__('onboarding')"
variant="fill"
/>
</p>
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| text | string | The plain text to print. It is always escaped, so markup in user data shows as text. | |
| ranges | array | [] | Matched ranges as [start, end] character offsets (end exclusive) or ['start' => …, 'end' => …]. Overlapping and touching ranges merge; out-of-range offsets are clamped. |
| query | string | null | null | Marks every case-insensitive occurrence of each word of the query, in addition to ranges. |
| variant | weight | fill | weight | weight turns the match into semibold foreground text and reads on any row background (also an active option); fill adds a quiet muted background for a results page. |
Slots
Default Blade slot only.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Offsets count characters (mb_*), not bytes, so accented and multibyte text marks the right letters.
- Each plain and matched part is printed with {{ }}; the component never outputs raw HTML.
- With no match the text prints plain, without a mark element.
- Declares registry capability flags: a11y, authoredStateFixtures, responsive, rtl, darkMode, localized.
Guidance
Present data for rapid scanning.
Use when
- Use to summarize, sequence, or present data so users can scan it quickly.
- A search result title or snippet shows which characters matched the query.
- The server returns match offsets, or the typed query is known when the text renders.
Avoid when
- Do not add display-only ornament when the user needs actionable structure or exact comparison instead.
- The text needs emphasis that is not a match; use strong or em, or prose for a formatted passage.
- The text is rendered in the browser from client data; mark it there with text nodes, never with innerHTML.
Use instead
- Table for exact comparison
- Plain text for a single value
Anti-patterns
- Adding display ornament without informational value
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- No component-owned keyboard interaction; native element behavior applies.
- Focus
none
- Uses the native mark element; the full text stays one readable string for assistive technology.
- The match is shown by weight (and a background in the fill variant), not by colour alone.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Livewire can update this component through forwarded wire:* attributes.
Source
The exact, editable file ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
// The plain text to print. It is always escaped; markup in it shows as text.
'text' => '',
// Matched ranges as [start, end] character offsets (end exclusive), or
// ['start' => …, 'end' => …]. Offsets count characters, not bytes.
// Overlapping and touching ranges merge; out-of-range offsets are clamped.
'ranges' => [],
// Mark every case-insensitive occurrence of each word of this query
// instead of (or as well as) `ranges`.
'query' => null,
// weight: the match turns bold foreground text (reads on any row,
// also an active one). fill: a quiet muted background as well.
'variant' => 'weight',
])
@php
// Accept a string, a backed enum or a Stringable for `variant`.
$styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
$variant = $styles['normalizeVariant']($variant);
$text = (string) $text;
$length = mb_strlen($text);
$spans = [];
foreach ((array) $ranges as $range) {
if (! is_array($range)) {
continue;
}
$start = $range['start'] ?? $range[0] ?? null;
$end = $range['end'] ?? $range[1] ?? null;
if (! is_numeric($start) || ! is_numeric($end)) {
continue;
}
$spans[] = [max(0, (int) $start), min($length, (int) $end)];
}
foreach (preg_split('/\s+/u', trim((string) $query), -1, PREG_SPLIT_NO_EMPTY) ?: [] as $term) {
$termLength = mb_strlen($term);
for ($offset = 0; ($position = mb_stripos($text, $term, $offset)) !== false; $offset = $position + $termLength) {
$spans[] = [$position, min($length, $position + $termLength)];
}
}
$spans = array_values(array_filter($spans, fn (array $span): bool => $span[0] < $span[1]));
usort($spans, fn (array $a, array $b): int => $a[0] <=> $b[0]);
$merged = [];
foreach ($spans as [$start, $end]) {
$last = array_key_last($merged);
if ($last !== null && $start <= $merged[$last][1]) {
$merged[$last][1] = max($merged[$last][1], $end);
} else {
$merged[] = [$start, $end];
}
}
$parts = [];
$cursor = 0;
foreach ($merged as [$start, $end]) {
if ($start > $cursor) {
$parts[] = ['text' => mb_substr($text, $cursor, $start - $cursor), 'match' => false];
}
$parts[] = ['text' => mb_substr($text, $start, $end - $start), 'match' => true];
$cursor = $end;
}
if ($cursor < $length) {
$parts[] = ['text' => mb_substr($text, $cursor), 'match' => false];
}
$markClass = $variant === 'fill'
? 'rounded-xs bg-muted font-medium text-foreground box-decoration-clone'
: 'bg-transparent font-semibold text-foreground';
@endphp
{{-- Highlight: plain text with its matched ranges in native <mark> elements.
Every part is printed escaped, so a title or snippet from user data can
never inject markup. The newline after @endif keeps the two directives
apart (PHP drops it after the closing tag, so no space is printed). --}}
<span data-slot="highlight" data-variant="{{ $variant === 'fill' ? 'fill' : 'weight' }}" {{ $attributes }}>@foreach ($parts as $part)@if ($part['match'])<mark data-slot="highlight-mark" class="{{ $markClass }}">{{ $part['text'] }}</mark>@else{{ $part['text'] }}@endif
@endforeach</span>
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.0.2 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0