Typeset
Long-form content, such as Markdown or chat replies, set for reading.
Building a Live Search Box
The useSearch hook turns a text field into instant search. It debounces typing, cancels stale requests, and keeps results in step with the query so the list never flashes old matches.
In short, useSearch gives you:
- Debounced queries: requests wait until typing pauses, so the server sees one call instead of ten.
- Cancellation: a new query aborts the previous request before its results arrive.
- Result state: loading, empty, and error states come back with the matches.
This guide builds a search box for a project list. For keyboard navigation through results, see the command menu guide.
Example
Each keystroke follows the same path:
- The field updates
queryimmediately so typing never lags. - After 200ms without typing, the hook sends the request.
- Results replace the list only if they belong to the latest query.
1234567891011121314import { useSearch } from "@/hooks/use-search"
export function ProjectSearch() { const { query, setQuery, results, status } = useSearch({ endpoint: "/api/projects/search", debounce: 200 })
return ( <SearchField value={query} onValueChange={setQuery} status={status}> {results.map((project) => <Result key={project.id} project={project} />)} </SearchField> )}Search should feel like filtering, not waiting. Show what you have immediately and refine it as better results arrive.
Customizing results
Pass transform to reshape each result before it renders, for example to highlight the matched text or group projects by team.
Status
The status value is one of:
idle: the field is empty and no request has been made.loading: a request is in flight; earlier results stay visible.ready: results match the current query.error: the last request failed; retry keeps the current query.
Options
| Option | Default | Description |
|---|---|---|
debounce | 200 | Milliseconds to wait after typing stops. |
minLength | 1 | Characters required before searching. |
limit | 20 | Maximum results per request. |
Results are cached per query for the life of the component, so clearing the field and typing the same words again is instant.
When to use it
Wrap rendered Markdown, help articles, and chat replies in Typeset. It applies the shared type roles with reading rhythm, so you don’t style each element.
Keep app interface out of it: settings rows, menus, and controls use their own denser roles.
How it behaves
Wrap rendered content in Typeset; it styles headings, paragraphs, lists, links, inline code, code blocks, quotes, tables, rules, and images inside it.
Headings reuse heading-24, heading-20, and heading-16; text uses body-md in the lighter --foreground-body tone. App UI keeps its denser roles outside Typeset.
Spacing is relative to the body size. Lists sit close to the sentence that introduces them, and blocks get a little more room than paragraphs.
Set --typeset-flow, --typeset-block, or --typeset-tight through className to tighten or loosen the rhythm, for example in a chat reply.
Plain pre blocks from Markdown scroll horizontally without bouncing. Render fenced code with CodeBlock for highlighting; Typeset spaces it like any other block. Links keep a faint underline so they are not identified by color alone.
Build it
Add it with the shadcn CLI:
1pnpm dlx shadcn@latest add https://jzbui.com/r/typeset.jsonA complete example. Change the values to fit your product:
123456789101112import { Typeset } from "@/components/ui/typeset"
<Typeset> <h2>Goals</h2> <p>Keep every edit, even offline.</p> <ul> <li><strong>Stay fast</strong>: sync in the background.</li> </ul></Typeset>
// Rendered Markdown: <Typeset dangerouslySetInnerHTML={{ __html: html }} />// Denser rhythm: <Typeset className="[--typeset-flow:0.75em]">…</Typeset>The full source is yours to edit once installed: