shshadcn-htmx

Components

Highlight

Wraps the words that matched a search query in a native <mark>, the semantic element for text relevant to the user's current activity. Rendered entirely on the server, styled with theme tokens, zero client JS — drop it inside the fragment htmx swaps into your results list.

Installation

1. Install via the shadcn CLI

npx shadcn@latest add http://localhost/r/highlight.json

2. Use it

components/ui/highlight.tsx
import { Highlight } from "@/components/ui/highlight"

// Scan mode — wrap each match of the query inside the text:
<Highlight text="Several species of salamander" query="salamander" />

// Single-term mode — the server already sliced the exact run:
<p>Evading the dreaded <Highlight>Imperial</Highlight> Starfleet.</p>
Or copy the source manually
components/ui/highlight.tsx
/** @jsxImportSource hono/jsx */
import type { Child } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"

// Highlight — shadcn-htmx, htmx v4 + Tailwind v4.
//
// Semantic marker for text that is relevant to the user's current activity —
// the canonical case is the words that matched a search query. It is a thin,
// server-rendered wrapper around the native <mark> element; the server splits
// a string on the query terms and wraps each match in a styled <mark>.
//
// Source of truth:
//   - <mark> semantics + the "search results" use case + the screen-reader
//     announcement note:
//     repos/mdn/files/en-us/web/html/reference/elements/mark/index.md
//     ("`<mark>` indicates a portion of the document's content which is likely
//      to be relevant to the user's current activity … the words that matched
//      a search operation." MDN also notes that `<mark>` is NOT announced by
//      default — which is correct here: a highlighted search match is a visual
//      affordance, not extra content to read out. Do not abuse the
//      ::before/::after announcement trick on a results list.)
//   - Don't use <mark> for syntax highlighting — use <span> (MDN, same file).
//     This component is strictly for relevance, never decoration.
//
// htmx: nothing of its own. The component just produces the marked-up HTML;
// the server renders it inside whatever fragment htmx swaps in (e.g. the
// <tr> rows returned to an Active Search `hx-target`). It forwards hx-*/data-*/
// aria-* via {...rest} so a single highlighted term can also be a swap hook.
// Verified there is no <mark>-specific htmx attribute:
//   repos/htmx/www/reference.md
//
// JS budget: none. Pure SSR + one CSS rule's worth of utility classes. The
// native <mark> default is a yellow background; we reset it to theme tokens so
// it reads on brand in light and dark and never clashes with selection colours.
//
// Accessibility:
//   - Keep highlighting to genuine matches. WCAG 1.4.1 (Use of Color): the
//     match must not rely on the tint alone to be perceivable — <mark> is a
//     real semantic element, and the bold weight + rounded chip give a
//     non-colour cue, so a match survives in a high-contrast / forced-colours
//     theme.
//   - Case-insensitive matching by default; the ORIGINAL casing of the source
//     text is preserved in the output (we slice the source, never the query).

// Reset the UA yellow default and paint with theme tokens so a match reads on
// brand in both schemes. bg-primary/15 is a soft tint of the brand colour;
// text-foreground keeps body contrast; the rounded chip + font-medium are the
// non-colour cue (WCAG 1.4.1). box-decoration-clone keeps the chip intact when
// a match wraps across lines.
const markBase =
  "rounded-sm bg-primary/15 px-0.5 font-medium text-foreground [box-decoration-break:clone]"

export function highlightClasses(opts?: { class?: ClassValue }): string {
  return cn(markBase, opts?.class)
}

// Escape a user-supplied query for safe use inside a RegExp.
function escapeRegExp(s: string): string {
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}

// Split `text` on every occurrence of `query` (case-insensitive, whole-string
// or per-word) and return an alternating list of plain strings and { match }
// segments. The source casing is preserved — we only ever slice `text`.
export type HighlightSegment = { text: string; match: boolean }

export function splitMatches(
  text: string,
  query: string | undefined,
  opts?: { words?: boolean; caseSensitive?: boolean },
): HighlightSegment[] {
  const q = (query ?? "").trim()
  if (q.length === 0) return [{ text, match: false }]

  // words:true highlights each whitespace-separated term independently
  // (multi-term search). Otherwise the whole query is one phrase.
  const terms = opts?.words ? q.split(/\s+/).filter(Boolean) : [q]
  if (terms.length === 0) return [{ text, match: false }]

  const flags = opts?.caseSensitive ? "g" : "gi"
  const re = new RegExp(`(${terms.map(escapeRegExp).join("|")})`, flags)

  const segments: HighlightSegment[] = []
  let last = 0
  for (const m of text.matchAll(re)) {
    const start = m.index
    if (start > last) segments.push({ text: text.slice(last, start), match: false })
    segments.push({ text: m[0], match: true })
    last = start + m[0].length
  }
  if (last < text.length) segments.push({ text: text.slice(last), match: false })
  return segments.length > 0 ? segments : [{ text, match: false }]
}

type HighlightProps = {
  // The source text to scan. Its original casing is preserved in the output.
  text?: string
  // The query to mark inside `text`. Empty/undefined renders `text` verbatim.
  query?: string
  // Highlight each whitespace-separated term in `query` independently.
  words?: boolean
  // Match case exactly (default: case-insensitive).
  caseSensitive?: boolean
  // Alternative "single term" mode: wrap the children verbatim in one <mark>.
  // Use when the server already knows the exact run to mark (no scanning).
  children?: Child
  class?: ClassValue
  // hx-* / data-* / aria-* ride onto the <mark> (or the wrapper in scan mode).
  [key: `hx-${string}`]: any
  [key: `data-${string}`]: any
  [key: `aria-${string}`]: any
}

export function Highlight(props: HighlightProps) {
  const {
    text,
    query,
    words,
    caseSensitive,
    children,
    class: className,
    ...rest
  } = props

  const classes = highlightClasses({ class: className })

  // Single-term mode: the caller hands us the exact run to mark.
  if (children !== undefined) {
    return (
      <mark data-slot="highlight" class={classes} {...rest}>
        {children}
      </mark>
    )
  }

  // Scan mode: split the source on the query and wrap each match. The root
  // carries data-slot="highlight" so the whole rendered run is one styling /
  // testing hook even though only the matched bits are <mark>.
  const segments = splitMatches(text ?? "", query, { words, caseSensitive })
  return (
    <span data-slot="highlight" {...rest}>
      {segments.map((seg) =>
        seg.match ? <mark class={classes}>{seg.text}</mark> : seg.text,
      )}
    </span>
  )
}

1. Save the file

Copy highlight.html into templates/components/.

2. Use it

templates/components/highlight.html
{% from "components/highlight.html" import highlight, mark %}

{{ highlight("Several species of salamander", query="salamander") }}

<p>Evading the dreaded {% call mark() %}Imperial{% endcall %} Starfleet.</p>
View source
templates/components/highlight.html
{# Highlight macro — shadcn-htmx, htmx v4 + Tailwind v4.
   Mirrors registry/ui/highlight.tsx. Wraps query matches in a styled native
   <mark>; the marker is the semantic element for "text relevant to the user's
   current activity" — the words that matched a search.
     repos/mdn/files/en-us/web/html/reference/elements/mark/index.md
   The UA yellow default is reset to theme tokens (bg-primary/15 + text-foreground)
   so it reads on brand in light/dark. No htmx attribute is <mark>-specific
   (repos/htmx/www/reference.md); hx-*/data-*/aria-* ride through **attrs.

   Two modes, same as the .tsx:
     1. highlight(text, query)  — scan `text` and mark each match.
     2. mark(...) as a {% call %} block — wrap a body the server already
        sliced (single-term mode).

   Usage:
     {% from "components/highlight.html" import highlight, mark %}
     {{ highlight("Several species of salamander", query="salamander") }}
     {% call mark() %}Imperial{% endcall %} #}

{%- set _mark_class = "rounded-sm bg-primary/15 px-0.5 font-medium text-foreground [box-decoration-break:clone]" -%}

{# Single-term mode: wrap the body verbatim in one <mark>. #}
{% macro mark(extra_class="", **attrs) %}
<mark data-slot="highlight" class="{{ _mark_class }} {{ extra_class }}"
  {%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>{{ caller() }}</mark>
{%- endmacro %}

{# Scan mode: split `text` on `query` (case-insensitive by default) and wrap
   each match. Source casing is preserved — only `text` is sliced. `words=true`
   marks each whitespace-separated term independently. #}
{% macro highlight(text, query=none, words=false, case_sensitive=false, extra_class="", **attrs) %}
<span data-slot="highlight"
  {%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>
{%- set q = (query | default("", true)) | trim -%}
{%- if q | length == 0 -%}
  {{ text }}
{%- else -%}
  {%- set terms = (q.split() if words else [q]) -%}
  {%- set hay = text if case_sensitive else text | lower -%}
  {%- set ns = namespace(i=0) -%}
  {%- for _ in text -%}
    {%- if ns.i < (text | length) -%}
      {# find the earliest matching term at or after ns.i #}
      {%- set best = namespace(at=-1, len=0) -%}
      {%- for t in terms -%}
        {%- set needle = t if case_sensitive else t | lower -%}
        {%- set pos = hay.find(needle, ns.i) -%}
        {%- if pos != -1 and (best.at == -1 or pos < best.at) -%}
          {%- set best.at = pos -%}
          {%- set best.len = needle | length -%}
        {%- endif -%}
      {%- endfor -%}
      {%- if best.at == -1 -%}
        {{- text[ns.i:] -}}
        {%- set ns.i = text | length -%}
      {%- else -%}
        {{- text[ns.i:best.at] -}}
        <mark class="{{ _mark_class }} {{ extra_class }}">{{ text[best.at:best.at + best.len] }}</mark>
        {%- set ns.i = best.at + best.len -%}
      {%- endif -%}
    {%- endif -%}
  {%- endfor -%}
{%- endif -%}
</span>
{%- endmacro %}

1. Save the file

Add highlight.tmpl alongside your templates.

2. Use it

components/highlight.tmpl
{{/* Scan in Go, pass ordered segments: */}}
{{template "highlight" (dict "Segments" $segs)}}

{{/* Single-term mode: */}}
<p>Evading the dreaded {{template "mark" (dict "Body" "Imperial")}} Starfleet.</p>
View source
components/highlight.tmpl
{{/*
  Highlight template — shadcn-htmx, htmx v4 + Tailwind v4.
  Mirrors registry/ui/highlight.tsx. Wraps query matches in a styled native
  <mark> — the semantic element for "text relevant to the user's current
  activity", i.e. the words that matched a search.
    repos/mdn/files/en-us/web/html/reference/elements/mark/index.md
  The UA yellow default is reset to theme tokens (bg-primary/15 + text-foreground)
  so a match reads on brand in light/dark. No htmx attribute is <mark>-specific
  (repos/htmx/www/reference.md); pass hx-*/data-* through .Attrs.

  Go has no inline regex in templates, so the SCAN happens in Go and you pass
  the result as an ordered []Segment. This keeps the same semantic HTML as the
  other flavours (alternating text + <mark> runs), with the split logic where
  Go wants it.

      type Segment struct {
          Text  string
          Match bool
      }
      type HighlightArgs struct {
          Segments  []Segment         // ordered runs from your splitMatches in Go
          Attrs     map[string]string // hx-*/data-*/aria-* on the wrapper
          ExtraClass string
      }
      // Single-term mode:
      type MarkArgs struct {
          Body       string            // exact run to mark (already sliced)
          Attrs      map[string]string
          ExtraClass string
      }

  Usage:
    {{template "highlight" (dict "Segments" $segs)}}
    {{template "mark" (dict "Body" "Imperial")}}
*/}}

{{- define "_highlight_mark_class" -}}rounded-sm bg-primary/15 px-0.5 font-medium text-foreground [box-decoration-break:clone]{{- end -}}

{{/* Single-term mode: wrap a server-sliced run in one <mark>. */}}
{{define "mark"}}
{{- $extra := or .ExtraClass "" -}}
<mark data-slot="highlight" class="{{template "_highlight_mark_class"}} {{$extra}}"
  {{- range $k, $v := .Attrs}} {{$k}}="{{$v}}"{{end -}}
>{{.Body}}</mark>
{{end}}

{{/* Scan mode: range over the pre-split segments; mark the matched ones. */}}
{{define "highlight"}}
{{- $extra := or .ExtraClass "" -}}
<span data-slot="highlight"
  {{- range $k, $v := .Attrs}} {{$k}}="{{$v}}"{{end -}}
>
{{- range .Segments -}}
  {{- if .Match -}}
    <mark class="{{template "_highlight_mark_class"}} {{$extra}}">{{.Text}}</mark>
  {{- else -}}
    {{- .Text -}}
  {{- end -}}
{{- end -}}
</span>
{{end}}

1. Save the file

Drop highlight.ex into lib/my_app_web/components/.

2. Use it

lib/my_app_web/components/highlight.ex
<.highlight text="Several species of salamander" query="salamander" />

<p>Evading the dreaded <.mark>Imperial</.mark> Starfleet.</p>
View source
lib/my_app_web/components/highlight.ex
defmodule ShadcnHtmx.Components.Highlight do
  @moduledoc """
  Highlight — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.

  Mirrors registry/ui/highlight.tsx. Wraps query matches in a styled native
  `<mark>` — the semantic element for "text relevant to the user's current
  activity", i.e. the words that matched a search.

    * repos/mdn/files/en-us/web/html/reference/elements/mark/index.md
      (`<mark>` marks "a portion of the document's content which is likely to be
      relevant to the user's current activity … the words that matched a search
      operation." Don't use it for syntax highlighting — that's a `<span>`.)

  The UA yellow `<mark>` default is reset to theme tokens (`bg-primary/15` +
  `text-foreground`) so a match reads on brand in light and dark.

  htmx: nothing of its own. The component renders the marked-up HTML the server
  swaps into an `hx-target`; no `<mark>`-specific attribute exists
  (repos/htmx/www/reference.md). `hx-*/data-*/aria-*` ride through `@rest`.

  Two modes:

    * `<.highlight text="…" query="…" />` — scan `text` and mark each match.
      Source casing is preserved; the query is escaped before matching.
    * `<.mark>Imperial</.mark>` — wrap a body the server already sliced.

  ## Examples

      <.highlight text="Several species of salamander" query="salamander" />
      <.mark>Imperial</.mark>
  """

  use Phoenix.Component

  @mark_class "rounded-sm bg-primary/15 px-0.5 font-medium text-foreground [box-decoration-break:clone]"

  # Single-term mode: wrap a server-sliced run in one <mark>.
  attr :class, :string, default: nil
  attr :rest, :global, include: ~w(hx-get hx-post hx-target hx-swap hx-trigger)
  slot :inner_block, required: true

  def mark(assigns) do
    assigns = assign(assigns, :mark_class, @mark_class)

    ~H"""
    <mark data-slot="highlight" class={[@mark_class, @class]} {@rest}>
      {render_slot(@inner_block)}
    </mark>
    """
  end

  # Scan mode: split `text` on `query` and mark each match.
  attr :text, :string, default: ""
  attr :query, :string, default: nil
  attr :words, :boolean, default: false
  attr :case_sensitive, :boolean, default: false
  attr :class, :string, default: nil
  attr :rest, :global, include: ~w(hx-get hx-post hx-target hx-swap hx-trigger)

  def highlight(assigns) do
    assigns =
      assigns
      |> assign(:mark_class, @mark_class)
      |> assign(
        :segments,
        split_matches(assigns.text, assigns.query,
          words: assigns.words,
          case_sensitive: assigns.case_sensitive
        )
      )

    ~H"""
    <span data-slot="highlight" {@rest}><%= for seg <- @segments do %><%= if seg.match do %><mark class={[@mark_class, @class]}>{seg.text}</mark><% else %>{seg.text}<% end %><% end %></span>
    """
  end

  @doc """
  Split `text` on every occurrence of `query` (case-insensitive by default).
  Returns an ordered list of `%{text: binary, match: boolean}` runs; the source
  casing is preserved. `words: true` marks each whitespace term independently.
  """
  def split_matches(text, query, opts \\ []) do
    q = String.trim(query || "")

    if q == "" do
      [%{text: text, match: false}]
    else
      terms = if opts[:words], do: String.split(q, ~r/\s+/, trim: true), else: [q]

      flags = if opts[:case_sensitive], do: "", else: "i"
      pattern = terms |> Enum.map(&Regex.escape/1) |> Enum.join("|")
      re = Regex.compile!("(#{pattern})", flags)

      Regex.split(re, text, include_captures: true, trim: true)
      |> Enum.map(fn part -> %{text: part, match: Regex.match?(re, part)} end)
      |> case do
        [] -> [%{text: text, match: false}]
        segs -> segs
      end
    end
  end
end

1. Save the file

Paste the markup; relies only on theme tokens.

2. Use it

snippets/highlight.html
<span data-slot="highlight">Several species of
  <mark class="rounded-sm bg-primary/15 px-0.5 font-medium text-foreground …">salamander</mark>
  inhabit the temperate rainforest.</span>
View source
snippets/highlight.html
<!--
  shadcn-htmx — raw Highlight markup.

  Mirrors registry/ui/highlight.tsx. The server wraps the words that matched a
  search query in a native <mark>; <mark> is the semantic element for "text
  relevant to the user's current activity".
    repos/mdn/files/en-us/web/html/reference/elements/mark/index.md

  The browser's default <mark> is a yellow background; the classes below reset
  it to theme tokens so a match reads on brand in light and dark:

    rounded-sm bg-primary/15 px-0.5 font-medium text-foreground
    [box-decoration-break:clone]

  No script and no <mark>-specific htmx attribute (repos/htmx/www/reference.md):
  the highlighting is produced server-side and rendered into whatever fragment
  htmx swaps in. Relies only on theme tokens.
-->

<!-- Scan mode: the wrapper carries data-slot="highlight"; only the matched
     runs are <mark>. Here the query was "salamander". -->
<p>
  Several species of
  <span data-slot="highlight">Several species of <mark class="rounded-sm bg-primary/15 px-0.5 font-medium text-foreground [box-decoration-break:clone]">salamander</mark> inhabit the temperate rainforest.</span>
</p>

<!-- Inside an htmx-swapped search result row -->
<li>
  <span data-slot="highlight">Evading the dreaded <mark class="rounded-sm bg-primary/15 px-0.5 font-medium text-foreground [box-decoration-break:clone]">Imperial</mark> Starfleet…</span>
</li>

<!-- Single-term mode: the server already sliced the exact run, so it's a bare
     <mark> with data-slot on it directly. -->
<p>
  Evading the dreaded
  <mark data-slot="highlight" class="rounded-sm bg-primary/15 px-0.5 font-medium text-foreground [box-decoration-break:clone]">Imperial</mark>
  Starfleet.
</p>

Examples

Mark the matched query inside a passage

Pass the source text and the query; the server scans the text and wraps each match in <mark>. The original casing of the source is preserved.

This is MDN's canonical use for <mark>: marking "a portion of the document's content which is likely to be relevant to the user's current activity … the words that matched a search operation." The browser's default yellow background is reset to bg-primary/15 so it reads on brand, and the bold weight plus rounded chip are a non-colour cue (WCAG 1.4.1) that survives a forced-colours theme.

Several species of salamander inhabit the temperate rainforest of the Pacific Northwest.

Most salamanders are nocturnal, and hunt for insects, worms, and other small creatures.

<Highlight
  text="Several species of salamander inhabit the temperate rainforest."
  query="salamander"
/>
{{ highlight("Several species of salamander inhabit the temperate rainforest.",
            query="salamander") }}
{{template "highlight" (dict "Segments" $segs)}}
<.highlight
  text="Several species of salamander inhabit the temperate rainforest."
  query="salamander"
/>
<div class="w-full max-w-md space-y-2 text-sm">
  <p>
    <span data-slot="highlight">
      Several species of
      <mark class="rounded-sm bg-primary/15 px-0.5 font-medium text-foreground [box-decoration-break:clone]">salamander</mark>
      inhabit the temperate rainforest of the Pacific Northwest.
    </span>
  </p>
  <p>
    <span data-slot="highlight">
      Most
      <mark class="rounded-sm bg-primary/15 px-0.5 font-medium text-foreground [box-decoration-break:clone]">salamander</mark>
      s are nocturnal, and hunt for insects, worms, and other small creatures.
    </span>
  </p>
</div>

Live search results — htmx swaps marked rows in

An Active Search box fetches matching articles as you type; the server marks the matched query in each returned row before swapping it into the list. The highlighting is pure SSR — it just rides along in the htmx fragment.

Highlight has no htmx attributes of its own — it produces the marked HTML, and htmx swaps that fragment into the hx-target. The server echoes the query into each <Highlight> so the marks always reflect what the user typed. Try salamander or imperial.

  • Pacific Northwest salamanders

    Several species of salamander inhabit the temperate rainforest of the Pacific Northwest.

  • Nocturnal hunters

    Most salamanders are nocturnal, and hunt for insects, worms, and other small creatures.

  • Imperial Starfleet

    Evading the dreaded Imperial Starfleet, a group of freedom fighters established a new base.

  • Ice world of Hoth

    Imperial troops drove the Rebel forces from their hidden base and pursued them across the galaxy.

<ActiveSearch id="q" action="/search"
  hx-target="#results" hx-swap="innerHTML" />
<ul id="results"></ul>

// On the server, for each result row:
<Highlight text={article.title} query={q} words />
{{ active_search(id="q", action="/search",
                 hx_target="#results", hx_swap="innerHTML") }}
<ul id="results"></ul>

{# server-side, per row: #}
{{ highlight(article.title, query=q, words=true) }}
{{template "active-search" (dict "ID" "q" "Action" "/search"
  "HxTarget" "#results" "HxSwap" "innerHTML")}}
<ul id="results"></ul>

{{/* server-side, per row: */}}
{{template "highlight" (dict "Segments" $segs)}}
<.active_search id="q" action={~p"/search"}
  hx-target="#results" hx-swap="innerHTML" />
<ul id="results"></ul>

<%# server-side, per row: %>
<.highlight text={article.title} query={@q} words />
<div class="grid w-full max-w-md gap-3">
  <label for="ex-hl-q" class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">Search articles</label>
  <form data-slot="active-search" role="search" class="relative w-full" action="/docs/highlight/search" method="get">
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" aria-hidden="true">
      <circle cx="11" cy="11" r="8">
      </circle>
      <path d="m21 21-4.3-4.3">
      </path>
    </svg>
    <input type="search" id="ex-hl-q" name="q" placeholder="Try &quot;salamander&quot; or &quot;imperial&quot;…" autocomplete="off" enterkeyhint="search" inputmode="search" data-slot="active-search-input" class="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent py-1 pr-9 pl-9 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;::-webkit-search-cancel-button]:hidden" hx-get="/docs/highlight/search" hx-trigger="input changed delay:300ms, search" hx-sync="this:replace" hx-indicator="#ex-hl-q-indicator" hx-target="#ex-hl-results" hx-swap="innerHTML"/>
    <span id="ex-hl-q-indicator" data-slot="active-search-indicator" role="status" aria-live="polite" class="htmx-indicator pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 animate-spin" aria-hidden="true">
        <path d="M21 12a9 9 0 1 1-6.219-8.56">
        </path>
      </svg>
      <span class="sr-only">Searching…</span>
    </span>
  </form>
  <ul id="ex-hl-results" class="w-full overflow-hidden rounded-md border bg-card">
    <li class="border-b px-3 py-2 last:border-0">
      <p class="text-sm font-medium">
        <span data-slot="highlight">Pacific Northwest salamanders</span>
      </p>
      <p class="text-sm text-muted-foreground">
        <span data-slot="highlight">
          Several species of salamander inhabit the temperate rainforest of the Pacific Northwest.
        </span>
      </p>
    </li>
    <li class="border-b px-3 py-2 last:border-0">
      <p class="text-sm font-medium">
        <span data-slot="highlight">Nocturnal hunters</span>
      </p>
      <p class="text-sm text-muted-foreground">
        <span data-slot="highlight">
          Most salamanders are nocturnal, and hunt for insects, worms, and other small creatures.
        </span>
      </p>
    </li>
    <li class="border-b px-3 py-2 last:border-0">
      <p class="text-sm font-medium">
        <span data-slot="highlight">Imperial Starfleet</span>
      </p>
      <p class="text-sm text-muted-foreground">
        <span data-slot="highlight">
          Evading the dreaded Imperial Starfleet, a group of freedom fighters established a new base.
        </span>
      </p>
    </li>
    <li class="border-b px-3 py-2 last:border-0">
      <p class="text-sm font-medium">
        <span data-slot="highlight">Ice world of Hoth</span>
      </p>
      <p class="text-sm text-muted-foreground">
        <span data-slot="highlight">
          Imperial troops drove the Rebel forces from their hidden base and pursued them across the galaxy.
        </span>
      </p>
    </li>
  </ul>
</div>

Multi-term query — mark each word independently

With words, each whitespace-separated term in the query is marked on its own. Useful for multi-word searches where a single phrase match would highlight nothing.

Without words the whole query is treated as one phrase; with it, the query is split on whitespace and each term is matched separately. Matching is case-insensitive by default — pass caseSensitive to require an exact-case match.

Imperial troops drove the Rebel forces from their hidden base across the galaxy.

<Highlight
  text="Imperial troops drove the Rebel forces from their hidden base."
  query="rebel imperial"
  words
/>
{{ highlight("Imperial troops drove the Rebel forces from their hidden base.",
            query="rebel imperial", words=true) }}
{{/* split on each term in Go, then: */}}
{{template "highlight" (dict "Segments" $segs)}}
<.highlight
  text="Imperial troops drove the Rebel forces from their hidden base."
  query="rebel imperial"
  words
/>
<div class="w-full max-w-md space-y-2 text-sm">
  <p>
    <span data-slot="highlight">
      <mark class="rounded-sm bg-primary/15 px-0.5 font-medium text-foreground [box-decoration-break:clone]">Imperial</mark>
      troops drove the
      <mark class="rounded-sm bg-primary/15 px-0.5 font-medium text-foreground [box-decoration-break:clone]">Rebel</mark>
      forces from their hidden base across the galaxy.
    </span>
  </p>
</div>

Further reading

API Reference

Highlight

PropTypeDefaultDescription
textstring
The source text to scan for matches. Its original casing is preserved in the output (only this string is sliced, never the query). Omit in single-term mode.MDN<mark> element
querystring
The query to mark inside text. Empty or undefined renders text verbatim with no marks. The query is escaped before matching, so punctuation matches literally.
wordsbooleanfalse
Highlight each whitespace-separated term in query independently. Off treats the whole query as a single phrase.
caseSensitivebooleanfalse
Require an exact-case match. By default matching is case-insensitive.
childrenChild
Single-term mode: when present, the children are wrapped verbatim in one <mark> and text/query are ignored. Use when the server already knows the exact run to mark.
classstring
Extra Tailwind classes appended to the root element.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference