Skip to main content
vd3
  • Home
  • Docs
  • Foundation
  • Color palette
  • Typography
  • Icons
  • Golden ratio
  • Grid system
  • Shadows & glow
  • Theme
  • Theme switcher
  • Theme customizer
  • Core
  • Button
  • Badge
  • Alert
  • Card
  • Dropdown
  • Menu
  • Popover
  • Doc Search
  • Floating Action Button
  • Ripple
  • Expanding Cards
  • Spotlight
  • Timeline
  • Template
  • Navigation
  • Feedback
  • Modal
  • Toast
  • Tooltip
  • Chip
  • Skeleton
  • Preloader
  • Data display
  • Avatar
  • Table
  • Collection
  • Breadcrumb
  • Interactive
  • Tabs
  • Accordion
  • Flow
  • Draggable
  • Search
  • Canvas
  • Charts
  • Flowchart
  • Hex Grid
  • Draw
  • Media
  • Music Player
  • Image Box
  • Editors
  • Code Editor
  • Effects
  • Glass
  • Morph
  • Parallax
  • Forms
  • Form controls
  • Form validation
  • Datepicker
  • Timepicker
  • Rating
  • Switch
  • Slider
  • Stepper
  • Autocomplete
  • Transfer
  • Tree
  • Pagination
  • Button group
  • Primitives
  • Progress
  • Spinner
  • Code snippet
  • Icon
  • Layout
  • Primitives
  • Separator
  • Sidenav
  • Sticky
  • Waypoint (Scrollspy)
  • Off-canvas
  • Navbar
  • Footer
Doc Search

VdDocSearch is a combobox/listbox search over a collection of documents you supply. Feed it a DocSearchDoc[] and the useDocSearch composable ranks matches (title > category > keywords > content), debounces typing, highlights the matched terms, and wires full keyboard navigation plus a global ⌘K / CtrlK shortcut. The component is a thin, accessible shell — reach for the composable directly when you want to render results your own way.

Live Search (press ⌘K / Ctrl+K)

Try typing switch, toast, menu or install — matched terms are highlighted and results are grouped by category.

⌘K
<script setup lang="ts">
import { VdDocSearch, type DocSearchDoc } from "@vanduo-oss/vd3";
import { useRouter } from "vue-router";

const router = useRouter();

const docs: DocSearchDoc[] = [
  {
    title: "Button",
    category: "Components",
    href: "/components/button",
    content: "Buttons trigger actions and submit forms.",
    keywords: ["button", "cta", "action"],
  },
  { title: "Switch", category: "Components", href: "/components/switch" },
  // …more docs
];

function onSelect(result: DocSearchResult) {
  router.push(result.href); // result.href defaults to #<id>
}
</script>

<template>
  <VdDocSearch
    :data="docs"
    placeholder="Search the docs… (⌘K)"
    @select="onSelect"
  />
</template>
Selection & Search Events

Last @search: waiting for input…

Last @select: nothing chosen yet

Tuned: top 3, min 1, bold

:max-results="3", :min-query-length="1" and highlight-tag="strong" — searches on a single character and caps the list at three.

Headless — useDocSearch with custom markup

Same ranking, debounce and keyboard state, but the result list below is hand-rolled from the controller's reactive results — no VdDocSearch shell.

<script setup lang="ts">
import { ref } from "vue";
import { useDocSearch, type DocSearchDoc } from "@vanduo-oss/vd3";

const docs: DocSearchDoc[] = [/* … */];
const input = ref<HTMLInputElement | null>(null);

// Reactive source (ref / getter) is re-indexed automatically when it changes.
const { query, results, isOpen, activeIndex, select, handleKeydown } =
  useDocSearch(() => docs, {
    input,          // ⌘K / Ctrl+K focuses this element
    maxResults: 5,
    debounceMs: 150,
    onSelect: (r) => console.log("chose", r.href),
  });
</script>

<template>
  <input ref="input" v-model="query" @keydown="handleKeydown" />
  <ul v-if="isOpen">
    <li
      v-for="(r, i) in results"
      :key="r.id"
      :class="{ active: i === activeIndex }"
      @click="select(i)"
    >
      <!-- titleHtml is HTML-escaped by the composable; only the
           highlight tag is injected, so v-html is safe here. -->
      <span v-html="r.titleHtml" />
    </li>
  </ul>
</template>
API Reference

Usage

<script setup lang="ts">
import { VdDocSearch, type DocSearchDoc } from "@vanduo-oss/vd3";
import { useRouter } from "vue-router";

const router = useRouter();

const docs: DocSearchDoc[] = [
  {
    title: "Button",
    category: "Components",
    href: "/components/button",
    content: "Buttons trigger actions and submit forms.",
    keywords: ["button", "cta", "action"],
  },
  { title: "Switch", category: "Components", href: "/components/switch" },
  // …more docs
];

function onSelect(result: DocSearchResult) {
  router.push(result.href); // result.href defaults to #<id>
}
</script>

<template>
  <VdDocSearch
    :data="docs"
    placeholder="Search the docs… (⌘K)"
    @select="onSelect"
  />
</template>

Component API — props

PropDescription
:dataDocSearchDoc[] — the searchable documents (required).
:min-query-lengthQueries shorter than this never search (default 2).
:max-resultsMaximum results shown (default 10).
:debounce-msDebounce applied to typing, in ms (default 150).
:highlight-tagWrapper tag for matched terms; whitelist mark | span | strong | em (default 'mark').
:keyboard-shortcutEnable the ⌘K / Ctrl+K shortcut and its badge (default true).
:placeholderInput placeholder (default 'Search...').
:empty-titleEmpty-state heading (default 'No results found').
:empty-textEmpty-state body (default 'Try different keywords or check spelling').
:aria-labelListbox accessible name (default 'Search results').

Events

EventDescription
@select(result: DocSearchResult) — a result was chosen by click or Enter.
@search(query, results) — fired after a debounced search settles.
@openThe results panel opened.
@closeThe results panel closed.

The DocSearchDoc shape

FieldDescription
titlestring — primary label and the highest-weighted match field (required).
id?string — stable identifier; falls back to a slug of title.
content?string — body text; the lowest-weighted matches and the source of the excerpt.
category?string — grouping label; its slug also picks the result's category icon.
keywords?string[] — extra match terms; derived from title + content when omitted.
href?string — result destination (defaults to #<id>).
icon?string — explicit Phosphor icon class; falls back to the category icon.
The useDocSearch composable

useDocSearch(docs: MaybeRefOrGetter<DocSearchDoc[]>, options?) is the headless engine behind the component. The index is rebuilt whenever the reactive docs source changes, so a live array (ref or getter) stays in sync. Ranking weights a title match at +100 (with +50 for an exact title and +25 for a prefix), a category match at +50, a keyword match at +30, and a content match at +10; results are sorted by score and sliced to maxResults. It is SSR-safe — the only browser access (the global ⌘K listener) is registered on mount and released on unmount.

Headless usage

<script setup lang="ts">
import { ref } from "vue";
import { useDocSearch, type DocSearchDoc } from "@vanduo-oss/vd3";

const docs: DocSearchDoc[] = [/* … */];
const input = ref<HTMLInputElement | null>(null);

// Reactive source (ref / getter) is re-indexed automatically when it changes.
const { query, results, isOpen, activeIndex, select, handleKeydown } =
  useDocSearch(() => docs, {
    input,          // ⌘K / Ctrl+K focuses this element
    maxResults: 5,
    debounceMs: 150,
    onSelect: (r) => console.log("chose", r.href),
  });
</script>

<template>
  <input ref="input" v-model="query" @keydown="handleKeydown" />
  <ul v-if="isOpen">
    <li
      v-for="(r, i) in results"
      :key="r.id"
      :class="{ active: i === activeIndex }"
      @click="select(i)"
    >
      <!-- titleHtml is HTML-escaped by the composable; only the
           highlight tag is injected, so v-html is safe here. -->
      <span v-html="r.titleHtml" />
    </li>
  </ul>
</template>

Options

OptionDescription
minQueryLengthnumber — gate below which no search runs (default 2).
maxResultsnumber — cap on returned results (default 10).
debounceMsnumber — debounce before a settled search (default 150).
highlightTagstring — safe wrapper tag for matches, mark | span | strong | em (default 'mark').
keyboardShortcutboolean — enable the ⌘K / Ctrl+K focus shortcut (default true).
inputRef — the element ⌘K / Ctrl+K focuses (and selects).
categoryIconsRecord<string, string> — category-slug → Phosphor icon overrides for results without an icon.
onSelect / onSearch / onOpen / onCloseLifecycle hooks. VdDocSearch forwards these as its four emits.

Controller (return value)

MemberDescription
queryRef<string> — live input value; mutate it (v-model) to drive a debounced search.
resultsRef<DocSearchResult[]> — the current ranked results.
isOpenRef<boolean> — whether the results panel is open.
activeIndexRef<number> — highlighted result index for keyboard nav, or -1.
search(query?)Run the ranking synchronously (no debounce); resets activeIndex and returns the results.
open() / close()Open or close the results panel.
navigate(direction)Move activeIndex by direction, wrapping at the ends.
select(index?)Select a result (defaults to the active one): closes, clears the query, fires onSelect.
handleKeydown(event)Combobox key handler for the input (Arrow / Enter / Escape / Tab).
highlight(text, query?)HTML-escape text and wrap matches in the highlight tag.

The DocSearchResult shape (passed to @select)

FieldDescription
scorenumber — relevance; results sort by it, descending, then keep document order.
titleHtml / excerptHtmlHTML-escaped title / excerpt with matched terms wrapped in the highlight tag (safe for v-html).
excerptPlain-text window around the first content match.
categorySlugSlug of category; drives the category-icon colour via data-category.
id / title / category / content / href / iconCarried through from the matching DocSearchDoc (resolved to defaults).
Accessibility
  • The input is a role="combobox" with aria-autocomplete="list", aria-controls pointing at the listbox, and aria-expanded mirroring the open state.
  • The results panel is a role="listbox" (named by :aria-label) and each result is a role="option" with aria-selected; aria-activedescendant tracks the highlighted option.
  • Fully keyboard operable: ↓ opens/advances, ↑ moves back (both wrap), Enter selects, Esc and Tab close.
  • ⌘K / CtrlK focuses and selects the input from anywhere on the page (disable via :keyboard-shortcut="false").
  • Highlighted matches are HTML-escaped before the safe highlight tag is injected, so untrusted document text can be indexed without XSS risk.

vd3ui

Quick Links

  • Home
  • Docs
  • Changelog
  • About

Resources

  • GitHub
  • NPM
  • Kilo OSS
  • License

2026, Vanduo UI. MIT License.