UIPackage
Menu

Highlight

highlight ui
Edit on GitHub

Highlights matching substrings in text for search results. Supports string or regex queries, case-sensitive and whole-word matching, custom highlight tag, and a max-highlight cap.

Also available for React ->

Installation

$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/highlight.json
Named registry: npx shadcn-vue@latest add @uipkge/highlight Installs to: app/components/ui/highlight/

Examples

Props

Name Type / Values Default Required
text

Text to search within.

string required
query

Query string or RegExp to highlight.

string | RegExp required
highlightTag

HTML tag used to wrap matched substrings. Default 'mark'.

'mark''span'
'mark' optional
highlightClass

Class applied to each highlight wrapper.

HTMLAttributes['class'] optional
highlightStyle

Inline style applied to each highlight wrapper.

HTMLAttributes['style'] optional
caseSensitive

Case-sensitive matching. Default false.

boolean false optional
wholeWord

Match whole words only. Default false.

boolean false optional
maxHighlights

Cap the number of highlights rendered. 0 = unlimited. Default 0.

number 0 optional
class HTMLAttributes['class'] optional

Schema

Type aliases from this item's source — use them to shape the data you pass in.

Segment
interface Segment {
  text: string
  match: boolean
}

Files installed (2)

  • app/components/ui/highlight/Highlight.vue 3.7 kB
    <script setup lang="ts">
    import { computed, onMounted, watch, type HTMLAttributes } from 'vue'
    import { cn } from '@/lib/utils'
    
    interface Props {
      /** Text to search within. */
      text: string
      /** Query string or RegExp to highlight. */
      query: string | RegExp
      /** HTML tag used to wrap matched substrings. Default 'mark'. */
      highlightTag?: 'mark' | 'span'
      /** Class applied to each highlight wrapper. */
      highlightClass?: HTMLAttributes['class']
      /** Inline style applied to each highlight wrapper. */
      highlightStyle?: HTMLAttributes['style']
      /** Case-sensitive matching. Default false. */
      caseSensitive?: boolean
      /** Match whole words only. Default false. */
      wholeWord?: boolean
      /** Cap the number of highlights rendered. 0 = unlimited. Default 0. */
      maxHighlights?: number
      class?: HTMLAttributes['class']
    }
    
    const props = withDefaults(defineProps<Props>(), {
      highlightTag: 'mark',
      caseSensitive: false,
      wholeWord: false,
      maxHighlights: 0,
    })
    
    const emit = defineEmits<{
      /** Emitted when the query changes. Provides the total match count (before maxHighlights cap). */
      matchCount: [count: number]
    }>()
    
    interface Segment {
      text: string
      match: boolean
    }
    
    const segments = computed<Segment[]>(() => {
      const text = props.text
      const query = props.query
      if (!text) return []
      if (!query) return [{ text, match: false }]
    
      let pattern: RegExp
      if (query instanceof RegExp) {
        const flags = query.flags.includes('g') ? query.flags : query.flags + 'g'
        pattern = new RegExp(query.source, flags)
      } else {
        if (!query) return [{ text, match: false }]
        const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
        const body = props.wholeWord ? `\\b${escaped}\\b` : escaped
        const flags = props.caseSensitive ? 'g' : 'gi'
        pattern = new RegExp(body, flags)
      }
    
      const out: Segment[] = []
      let last = 0
      let count = 0
      let m: RegExpExecArray | null
      while ((m = pattern.exec(text)) !== null) {
        if (m.index > last) out.push({ text: text.slice(last, m.index), match: false })
        out.push({ text: m[0], match: true })
        last = m.index + m[0].length
        count++
        if (props.maxHighlights > 0 && count >= props.maxHighlights) break
        if (m[0] === '') pattern.lastIndex++
      }
      if (last < text.length) out.push({ text: text.slice(last), match: false })
    
      return out
    })
    
    // Total match count (uncapped) — emitted via watch to avoid side-effects in computed
    const totalMatchCount = computed(() => {
      const text = props.text
      const query = props.query
      if (!text || !query) return 0
    
      let pattern: RegExp
      if (query instanceof RegExp) {
        const flags = query.flags.includes('g') ? query.flags : query.flags + 'g'
        pattern = new RegExp(query.source, flags)
      } else {
        const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
        const body = props.wholeWord ? `\\b${escaped}\\b` : escaped
        const flags = props.caseSensitive ? 'g' : 'gi'
        pattern = new RegExp(body, flags)
      }
    
      let total = 0
      let m: RegExpExecArray | null
      while ((m = pattern.exec(text)) !== null) {
        total++
        if (m[0] === '') pattern.lastIndex++
      }
      return total
    })
    
    watch(totalMatchCount, (n) => emit('matchCount', n))
    onMounted(() => emit('matchCount', totalMatchCount.value))
    </script>
    
    <template>
      <span data-uipkge data-slot="highlight" :class="cn(props.class)">
        <template v-for="(seg, i) in segments" :key="i">
          <component
            :is="highlightTag"
            v-if="seg.match"
            data-slot="highlight-match"
            :class="cn('bg-yellow-200 text-yellow-950 dark:bg-yellow-500/30 dark:text-yellow-100 rounded px-0.5 font-medium', highlightClass)"
            :style="highlightStyle"
            >{{ seg.text }}</component
          >
          <template v-else>{{ seg.text }}</template>
        </template>
      </span>
    </template>
  • app/components/ui/highlight/index.ts 0.1 kB
    export { default as Highlight } from './Highlight.vue'

Raw manifest: https://uipkge.dev/r/vue/highlight.json