@headband/react 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @headband/react
2
2
 
3
- React hooks and components for [Headband](https://headband.dev) search. Drop-in building blocks for building fast, faceted search UIs -- designed as an InstantSearch-compatible alternative that works with Headband's unified search API.
3
+ React hooks and components for [Headband](https://headband.dev) search. Drop-in building blocks for building fast, faceted search UIs -- designed as an InstantSearch-inspired alternative that works with Headband's unified search API.
4
4
 
5
5
  ## Installation
6
6
 
@@ -10,6 +10,8 @@ npm install @headband/react
10
10
 
11
11
  **Peer dependencies:** React 18+ and React DOM 18+.
12
12
 
13
+ Browser SDK examples must use scoped `hb_src_*` search keys. Keep `hb_adm_*` admin keys server-side for indexing, settings, and connector jobs.
14
+
13
15
  ## Quick Start
14
16
 
15
17
  ```tsx
@@ -23,7 +25,7 @@ import {
23
25
  Stats,
24
26
  } from "@headband/react";
25
27
 
26
- const client = headband("https://your-headband-instance.com", "your-api-key");
28
+ const client = headband("https://your-headband-instance.com", "hb_src_YOUR_SEARCH_KEY");
27
29
 
28
30
  function App() {
29
31
  return (
@@ -68,6 +70,33 @@ HeadbandProvider -- owns all search state, executes searches
68
70
  |-- useSearch() -- full state + actions (escape hatch)
69
71
  ```
70
72
 
73
+ ### Base filters and full results pages
74
+
75
+ Use `baseFilter` on `HeadbandProvider` for app-owned constraints that should apply
76
+ to every query: tenant isolation, RBAC, publish status, availability, or custom
77
+ OR/AND groups. Refinement and range hooks keep adding UI filters, and Headband
78
+ ANDs them with `baseFilter` while preserving OR groups.
79
+
80
+ ```tsx
81
+ import { HeadbandProvider, filterEq, orFilters } from "@headband/react";
82
+
83
+ const availability = orFilters(filterEq("status", "published"), filterEq("status", "backorder"));
84
+
85
+ <HeadbandProvider
86
+ client={client}
87
+ index="products"
88
+ baseFilter={[filterEq("tenantId", "acme"), availability]}
89
+ >
90
+ <SearchBox />
91
+ <RefinementList attribute="brand" />
92
+ <RangeInput attribute="price" />
93
+ <Hits />
94
+ </HeadbandProvider>;
95
+ ```
96
+
97
+ This supports text search plus filters, filter-only browse pages, and mixed
98
+ boolean filters on every Headband engine.
99
+
71
100
  ---
72
101
 
73
102
  ## Client
@@ -79,15 +108,98 @@ Creates a Headband API client.
79
108
  ```ts
80
109
  import { headband } from "@headband/react";
81
110
 
82
- const client = headband("https://search.example.com", "your-api-key");
111
+ const client = headband("https://search.example.com", "hb_src_YOUR_SEARCH_KEY");
112
+ ```
113
+
114
+ | Param | Type | Description |
115
+ | -------- | -------- | -------------------------- |
116
+ | `host` | `string` | Headband instance URL |
117
+ | `apiKey` | `string` | API key for authentication |
118
+
119
+ Returns a `HeadbandClient` with `search()`, `multiSearch()`, `getExperimentConfigs()`, `assignExperiment()`, and `recordExperimentEvent()` methods.
120
+
121
+ ---
122
+
123
+ ## Retrieval experiments
124
+
125
+ Headband can store managed experiment rules, rollout percentages, variant weights, and kill-switch state. Apps can call hosted `client.assignExperiment()` when they want Headband to apply those rules, or use deterministic local helpers when offline/app-owned assignment is required.
126
+
127
+ ```tsx
128
+ import { HeadbandProvider, headband } from "@headband/react";
129
+
130
+ const client = headband("https://headband.dev", "hb_src_your_search_key");
131
+ const assignment = await client.assignExperiment({
132
+ indexUid: "products",
133
+ experimentKey: "engine-ab-test",
134
+ subjectId: sessionId,
135
+ sessionId,
136
+ });
137
+
138
+ if (assignment.assigned) {
139
+ await client.recordExperimentEvent({
140
+ eventType: "experiment_view",
141
+ experimentKey: assignment.experimentKey,
142
+ variant: assignment.variant!,
143
+ indexUid: assignment.indexUid!,
144
+ sessionId,
145
+ });
146
+ }
147
+
148
+ <HeadbandProvider
149
+ client={client}
150
+ index={assignment.assigned ? assignment.indexUid! : "products"}
151
+ experiment={
152
+ assignment.assigned
153
+ ? {
154
+ experimentKey: assignment.experimentKey,
155
+ variant: assignment.variant!,
156
+ sessionId: assignment.sessionId,
157
+ }
158
+ : undefined
159
+ }
160
+ >
161
+ <SearchBox />
162
+ <Hits />
163
+ </HeadbandProvider>;
83
164
  ```
84
165
 
85
- | Param | Type | Description |
86
- | -------- | -------- | --------------------------------- |
87
- | `host` | `string` | Headband instance URL |
88
- | `apiKey` | `string` | API key for authentication |
166
+ Hosted assignment only returns a variant when the rule is `active`, the kill switch is off, and the subject falls inside `trafficPercent`. For local assignment, `assignExperiment(config, subjectId)` uses `assignmentSalt`, `experimentKey`, and your subject/session ID for stable bucketing. Headband receives `experimentKey`, `variant`, and `sessionId` on search calls for analytics attribution.
167
+
168
+ ---
169
+
170
+ ## Click feedback
171
+
172
+ When click feedback is on for your organization, every search response carries a `queryId`. Send clicks and conversions with it, and Headband counts them per query. Turn on "Use click feedback in ranking" for an index (dashboard or `PATCH /v1/indexes/{uid}/click-feedback`) and results people pick for a query move up for that query. No experiment is needed.
173
+
174
+ ```tsx
175
+ import { useClickTracking, useHits } from "@headband/react";
176
+
177
+ function Results() {
178
+ const { hits } = useHits<{ id: string; title: string; url: string }>();
179
+ const { trackClick } = useClickTracking();
180
+ return (
181
+ <ul>
182
+ {hits.map((hit, i) => (
183
+ <li key={hit.__data.id}>
184
+ <a href={hit.__data.url} onClick={() => trackClick(hit, i)}>
185
+ {hit.__data.title}
186
+ </a>
187
+ </li>
188
+ ))}
189
+ </ul>
190
+ );
191
+ }
192
+
193
+ // Later, on the order page:
194
+ const { trackConversion } = useClickTracking();
195
+ trackConversion(productId, { value: 1999 });
196
+ ```
89
197
 
90
- Returns a `HeadbandClient` with `search()` and `multiSearch()` methods.
198
+ - `trackClick(hit, positionOnPage)` sends the hit's id, the search's `queryId`, its position in the whole result list and a session id. Events are sent with `keepalive`, so a click that opens a new page still arrives.
199
+ - `trackConversion(docId)` names the search the document was last clicked from in this tab.
200
+ - Ids are read from `id`. Set `idField` on `<HeadbandProvider>` if your documents use another field. Set `sessionId` to your own visitor session id; without it, the experiment's session id is used, then an id kept for the browser tab.
201
+ - Outside React: `client.trackClick({ index, docId, queryId, position, sessionId })`, `client.trackConversion({ index, docId, value })` and `client.sendEvents([...])` (up to 100 events).
202
+ - Tracking never throws from the hook. The client methods reject with a `HeadbandError`, as the other calls do.
91
203
 
92
204
  ---
93
205
 
@@ -98,21 +210,24 @@ Returns a `HeadbandClient` with `search()` and `multiSearch()` methods.
98
210
  Wraps your search UI and manages all search state.
99
211
 
100
212
  ```tsx
101
- <HeadbandProvider
102
- client={client}
103
- index="products"
104
- initialQuery="shoes"
105
- >
213
+ <HeadbandProvider client={client} index="products" initialQuery="shoes">
106
214
  {/* search components */}
107
215
  </HeadbandProvider>
108
216
  ```
109
217
 
110
- | Prop | Type | Default | Description |
111
- | -------------- | ----------------- | ------- | ---------------------------------------- |
112
- | `client` | `HeadbandClient` | -- | Client returned by `headband()` |
113
- | `index` | `string` | -- | Index to search |
114
- | `initialQuery` | `string` | `""` | Starting query value |
115
- | `children` | `ReactNode` | -- | Child components |
218
+ | Prop | Type | Default | Description |
219
+ | ------------------- | ---------------------------------- | ------- | ---------------------------------------------------------------- |
220
+ | `client` | `HeadbandClient` | -- | Client returned by `headband()` |
221
+ | `index` | `string` | -- | Index to search |
222
+ | `initialQuery` | `string` | `""` | Starting query value |
223
+ | `experiment` | `ExperimentSearchMetadata \| null` | `null` | Optional experiment telemetry attached to search calls |
224
+ | `mode` | `"instant" \| "retrieval"` | -- | Visor's ranking mode; other engines ignore it |
225
+ | `disjunctiveFacets` | `boolean` | `true` | Facet lists keep counting their other values after one is ticked |
226
+ | `children` | `ReactNode` | -- | Child components |
227
+
228
+ `mode` is also a field of `client.search()` params and of each `client.multiSearch()` query.
229
+ On a Visor index, `instant` (the default) is typo-tolerant, as-you-type search, and
230
+ `retrieval` ranks longer queries by BM25F.
116
231
 
117
232
  ---
118
233
 
@@ -126,11 +241,11 @@ Binds to the search input. Returns the current query plus setters.
126
241
  const { query, setQuery, clear } = useSearchBox();
127
242
  ```
128
243
 
129
- | Return | Type | Description |
130
- | ---------- | -------------------------- | ---------------------------------------------- |
131
- | `query` | `string` | Current query string |
132
- | `setQuery` | `(query: string) => void` | Update the query (resets pagination) |
133
- | `clear` | `() => void` | Clear the query to an empty string |
244
+ | Return | Type | Description |
245
+ | ---------- | ------------------------- | ------------------------------------ |
246
+ | `query` | `string` | Current query string |
247
+ | `setQuery` | `(query: string) => void` | Update the query (resets pagination) |
248
+ | `clear` | `() => void` | Clear the query to an empty string |
134
249
 
135
250
  ---
136
251
 
@@ -142,11 +257,11 @@ Returns the current search result hits with loading and error state.
142
257
  const { hits, isLoading, error } = useHits<Movie>();
143
258
  ```
144
259
 
145
- | Return | Type | Description |
146
- | ----------- | ------------- | ---------------------------------------------- |
147
- | `hits` | `Hit<T>[]` | Array of hit objects |
148
- | `isLoading` | `boolean` | Whether a search is in progress |
149
- | `error` | `Error\|null` | Error from the last search, if any |
260
+ | Return | Type | Description |
261
+ | ----------- | ------------- | ---------------------------------- |
262
+ | `hits` | `Hit<T>[]` | Array of hit objects |
263
+ | `isLoading` | `boolean` | Whether a search is in progress |
264
+ | `error` | `Error\|null` | Error from the last search, if any |
150
265
 
151
266
  Each `Hit<T>` includes the original document fields, an optional `_formatted` object with highlighted values, and a typed `__data: T` accessor.
152
267
 
@@ -164,21 +279,27 @@ const { items, refine, canToggle, isLoading } = useRefinementList({
164
279
  });
165
280
  ```
166
281
 
167
- | Prop | Type | Default | Description |
168
- | ----------- | -------------------- | --------- | ------------------------------------ |
169
- | `attribute` | `string` | -- | Facet attribute (e.g. `"genres"`) |
170
- | `limit` | `number` | `20` | Max facet values to show |
171
- | `sortBy` | `"count" \| "alpha"` | `"count"` | Sort order for facet values |
282
+ | Prop | Type | Default | Description |
283
+ | ----------- | -------------------- | --------- | --------------------------------- |
284
+ | `attribute` | `string` | -- | Facet attribute (e.g. `"genres"`) |
285
+ | `limit` | `number` | `20` | Max facet values to show |
286
+ | `sortBy` | `"count" \| "alpha"` | `"count"` | Sort order for facet values |
172
287
 
173
- | Return | Type | Description |
174
- | ----------- | ------------------------ | ------------------------------------- |
175
- | `items` | `RefinementListItem[]` | Facet values with count & state |
176
- | `refine` | `(value: string) => void`| Toggle a facet value on/off |
177
- | `canToggle` | `boolean` | Whether any values are available |
178
- | `isLoading` | `boolean` | Loading state |
288
+ | Return | Type | Description |
289
+ | ----------- | ------------------------- | -------------------------------- |
290
+ | `items` | `RefinementListItem[]` | Facet values with count & state |
291
+ | `refine` | `(value: string) => void` | Toggle a facet value on/off |
292
+ | `canToggle` | `boolean` | Whether any values are available |
293
+ | `isLoading` | `boolean` | Loading state |
179
294
 
180
295
  Each `RefinementListItem` has `{ value: string; count: number; isRefined: boolean }`.
181
296
 
297
+ Ticking a value does not hide the other values. The provider runs one more search for each facet
298
+ with a ticked value, filtered by everything except that facet's own values, and uses its counts
299
+ for the list. Each of those searches counts as a search on your plan. Pass
300
+ `disjunctiveFacets={false}` to `<HeadbandProvider>` for one search per request; the list then
301
+ shows only the values left in the results. New in 0.2.1: in 0.1.1 and 0.2.0 the list always narrows.
302
+
182
303
  ---
183
304
 
184
305
  ### `usePagination()`
@@ -190,15 +311,15 @@ const { currentPage, totalPages, totalHits, setPage, canPrevious, canNext, pages
190
311
  usePagination();
191
312
  ```
192
313
 
193
- | Return | Type | Description |
194
- | ------------- | ------------------------- | ------------------------------------ |
195
- | `currentPage` | `number` | Current page index (0-based) |
196
- | `totalPages` | `number` | Total number of pages |
197
- | `totalHits` | `number` | Total result count |
198
- | `setPage` | `(page: number) => void` | Navigate to a page (clamped) |
199
- | `canPrevious` | `boolean` | Whether a previous page exists |
200
- | `canNext` | `boolean` | Whether a next page exists |
201
- | `pages` | `number[]` | Window of page numbers for rendering |
314
+ | Return | Type | Description |
315
+ | ------------- | ------------------------ | ------------------------------------ |
316
+ | `currentPage` | `number` | Current page index (0-based) |
317
+ | `totalPages` | `number` | Total number of pages |
318
+ | `totalHits` | `number` | Total result count |
319
+ | `setPage` | `(page: number) => void` | Navigate to a page (clamped) |
320
+ | `canPrevious` | `boolean` | Whether a previous page exists |
321
+ | `canNext` | `boolean` | Whether a next page exists |
322
+ | `pages` | `number[]` | Window of page numbers for rendering |
202
323
 
203
324
  ---
204
325
 
@@ -210,12 +331,12 @@ Returns summary statistics about the current search.
210
331
  const { totalHits, processingTimeMs, query, isLoading } = useStats();
211
332
  ```
212
333
 
213
- | Return | Type | Description |
214
- | ----------------- | --------- | -------------------------------- |
215
- | `totalHits` | `number` | Total results for the query |
216
- | `processingTimeMs`| `number` | Server-side processing time (ms) |
217
- | `query` | `string` | The query that produced results |
218
- | `isLoading` | `boolean` | Loading state |
334
+ | Return | Type | Description |
335
+ | ------------------ | --------- | -------------------------------- |
336
+ | `totalHits` | `number` | Total results for the query |
337
+ | `processingTimeMs` | `number` | Server-side processing time (ms) |
338
+ | `query` | `string` | The query that produced results |
339
+ | `isLoading` | `boolean` | Loading state |
219
340
 
220
341
  ---
221
342
 
@@ -233,17 +354,17 @@ const { currentSort, setSort, items } = useSortBy({
233
354
  });
234
355
  ```
235
356
 
236
- | Prop | Type | Description |
237
- | ------- | -------------- | ----------------------------------------- |
238
- | `items` | `SortByItem[]` | Available sort options |
357
+ | Prop | Type | Description |
358
+ | ------- | -------------- | ---------------------- |
359
+ | `items` | `SortByItem[]` | Available sort options |
239
360
 
240
361
  Each `SortByItem` has `{ value: string; label: string }`.
241
362
 
242
- | Return | Type | Description |
243
- | ------------- | --------------------------------- | ------------------------------ |
363
+ | Return | Type | Description |
364
+ | ------------- | --------------------------------- | ------------------------------------ |
244
365
  | `currentSort` | `string \| null` | Active sort value (null = relevance) |
245
- | `setSort` | `(value: string\|null) => void` | Set the sort order |
246
- | `items` | `(SortByItem & { isSelected })[]` | Items with `isSelected` flag |
366
+ | `setSort` | `(value: string\|null) => void` | Set the sort order |
367
+ | `items` | `(SortByItem & { isSelected })[]` | Items with `isSelected` flag |
247
368
 
248
369
  ---
249
370
 
@@ -257,18 +378,18 @@ const { min, max, stats, setRange, clear, isLoading } = useRange({
257
378
  });
258
379
  ```
259
380
 
260
- | Prop | Type | Description |
261
- | ----------- | -------- | ------------------------------------- |
262
- | `attribute` | `string` | Numeric attribute (e.g. `"price"`) |
381
+ | Prop | Type | Description |
382
+ | ----------- | -------- | ---------------------------------- |
383
+ | `attribute` | `string` | Numeric attribute (e.g. `"price"`) |
263
384
 
264
- | Return | Type | Description |
265
- | ----------- | ------------------------------------------ | -------------------------------- |
266
- | `min` | `number \| undefined` | Current min bound |
267
- | `max` | `number \| undefined` | Current max bound |
268
- | `stats` | `{ min: number; max: number } \| undefined`| Overall min/max from engine |
269
- | `setRange` | `(range: { min?: number; max?: number }) => void` | Set range filter |
270
- | `clear` | `() => void` | Remove the range filter |
271
- | `isLoading` | `boolean` | Loading state |
385
+ | Return | Type | Description |
386
+ | ----------- | ------------------------------------------------- | --------------------------- |
387
+ | `min` | `number \| undefined` | Current min bound |
388
+ | `max` | `number \| undefined` | Current max bound |
389
+ | `stats` | `{ min: number; max: number } \| undefined` | Overall min/max from engine |
390
+ | `setRange` | `(range: { min?: number; max?: number }) => void` | Set range filter |
391
+ | `clear` | `() => void` | Remove the range filter |
392
+ | `isLoading` | `boolean` | Loading state |
272
393
 
273
394
  ---
274
395
 
@@ -283,15 +404,15 @@ const { value, highlighted } = useHighlight({
283
404
  });
284
405
  ```
285
406
 
286
- | Prop | Type | Description |
287
- | ----------- | ------------------------ | ---------------------------------- |
288
- | `hit` | `Record<string, unknown>`| The hit object from `useHits()` |
289
- | `attribute` | `string` | Attribute to highlight |
407
+ | Prop | Type | Description |
408
+ | ----------- | ------------------------- | ------------------------------- |
409
+ | `hit` | `Record<string, unknown>` | The hit object from `useHits()` |
410
+ | `attribute` | `string` | Attribute to highlight |
290
411
 
291
- | Return | Type | Description |
292
- | ------------- | --------- | ---------------------------------------------- |
293
- | `value` | `string` | Highlighted HTML string (safe for innerHTML) |
294
- | `highlighted` | `boolean` | Whether the value contains highlight marks |
412
+ | Return | Type | Description |
413
+ | ------------- | --------- | -------------------------------------------- |
414
+ | `value` | `string` | Highlighted HTML string (safe for innerHTML) |
415
+ | `highlighted` | `boolean` | Whether the value contains highlight marks |
295
416
 
296
417
  Supports nested attributes via dot notation (e.g. `"author.name"`).
297
418
 
@@ -303,9 +424,25 @@ Escape-hatch hook that exposes the full search state and all actions. Prefer the
303
424
 
304
425
  ```tsx
305
426
  const {
306
- query, results, isLoading, error, refinements, ranges, sortBy, page,
307
- hitsPerPage, facets, setQuery, setPage, setSortBy, setHitsPerPage,
308
- toggleRefinement, setRange, clearRange, registerFacet, unregisterFacet,
427
+ query,
428
+ results,
429
+ isLoading,
430
+ error,
431
+ refinements,
432
+ ranges,
433
+ sortBy,
434
+ page,
435
+ hitsPerPage,
436
+ facets,
437
+ setQuery,
438
+ setPage,
439
+ setSortBy,
440
+ setHitsPerPage,
441
+ toggleRefinement,
442
+ setRange,
443
+ clearRange,
444
+ registerFacet,
445
+ unregisterFacet,
309
446
  refresh,
310
447
  } = useSearch();
311
448
  ```
@@ -316,25 +453,35 @@ const {
316
453
 
317
454
  All components accept a `styled` prop (default `true`) that toggles built-in Tailwind CSS classes. Set `styled={false}` to bring your own styles.
318
455
 
456
+ The built-in styles are Tailwind classes inside this package. Tailwind v4 does not scan
457
+ `node_modules`, so add an `@source` line to the CSS file that imports Tailwind. The path is
458
+ relative to that CSS file. For a Next.js `app/globals.css`:
459
+
460
+ ```css
461
+ @import "tailwindcss";
462
+ @source "../node_modules/@headband/react";
463
+ ```
464
+
465
+ For `src/app/globals.css`, use `"../../node_modules/@headband/react"`.
466
+
319
467
  ### `<SearchBox>`
320
468
 
321
- Search input with built-in clear button and search icon.
469
+ Search input with built-in clear button and search icon. From 0.2.1 it is a text input with
470
+ `role="searchbox"`, so Chrome and Safari add no second clear button. On 0.1.1 and 0.2.0, hide the browser's
471
+ own button with
472
+ `input[type="search"]::-webkit-search-cancel-button { -webkit-appearance: none; }`.
322
473
 
323
474
  ```tsx
324
- <SearchBox
325
- placeholder="Search products..."
326
- autoFocus
327
- onQueryChange={(q) => console.log(q)}
328
- />
475
+ <SearchBox placeholder="Search products..." autoFocus onQueryChange={(q) => console.log(q)} />
329
476
  ```
330
477
 
331
- | Prop | Type | Default | Description |
332
- | --------------- | --------------------------- | -------------- | ---------------------------- |
333
- | `placeholder` | `string` | `"Search..."` | Input placeholder |
334
- | `className` | `string` | -- | Additional CSS classes |
335
- | `autoFocus` | `boolean` | `false` | Auto-focus on mount |
336
- | `onQueryChange` | `(query: string) => void` | -- | Called on every query change |
337
- | `styled` | `boolean` | `true` | Enable built-in styles |
478
+ | Prop | Type | Default | Description |
479
+ | --------------- | ------------------------- | ------------- | ---------------------------- |
480
+ | `placeholder` | `string` | `"Search..."` | Input placeholder |
481
+ | `className` | `string` | -- | Additional CSS classes |
482
+ | `autoFocus` | `boolean` | `false` | Auto-focus on mount |
483
+ | `onQueryChange` | `(query: string) => void` | -- | Called on every query change |
484
+ | `styled` | `boolean` | `true` | Enable built-in styles |
338
485
 
339
486
  ---
340
487
 
@@ -346,12 +493,12 @@ Renders search result hits in a grid layout.
346
493
  <Hits hitComponent={ProductCard} emptyComponent={<p>No results.</p>} />
347
494
  ```
348
495
 
349
- | Prop | Type | Default | Description |
350
- | ---------------- | ------------------------------- | -------------- | ------------------------------ |
351
- | `hitComponent` | `ComponentType<{ hit: Hit }>` | JSON card | Custom component per hit |
352
- | `className` | `string` | -- | CSS classes for the container |
353
- | `emptyComponent` | `ReactNode` | "No results" | Shown when there are no hits |
354
- | `styled` | `boolean` | `true` | Enable built-in styles |
496
+ | Prop | Type | Default | Description |
497
+ | ---------------- | ----------------------------- | ------------ | ----------------------------- |
498
+ | `hitComponent` | `ComponentType<{ hit: Hit }>` | JSON card | Custom component per hit |
499
+ | `className` | `string` | -- | CSS classes for the container |
500
+ | `emptyComponent` | `ReactNode` | "No results" | Shown when there are no hits |
501
+ | `styled` | `boolean` | `true` | Enable built-in styles |
355
502
 
356
503
  ---
357
504
 
@@ -363,14 +510,14 @@ Checkbox list of facet values with counts.
363
510
  <RefinementList attribute="brand" header="Brand" limit={10} sortBy="count" />
364
511
  ```
365
512
 
366
- | Prop | Type | Default | Description |
367
- | ----------- | -------------------- | --------- | -------------------------------- |
368
- | `attribute` | `string` | -- | Facet attribute |
369
- | `limit` | `number` | `20` | Max values to show |
370
- | `sortBy` | `"count" \| "alpha"` | `"count"` | Value sort order |
371
- | `header` | `string` | -- | Optional heading above the list |
372
- | `className` | `string` | -- | Additional CSS classes |
373
- | `styled` | `boolean` | `true` | Enable built-in styles |
513
+ | Prop | Type | Default | Description |
514
+ | ----------- | -------------------- | --------- | ------------------------------- |
515
+ | `attribute` | `string` | -- | Facet attribute |
516
+ | `limit` | `number` | `20` | Max values to show |
517
+ | `sortBy` | `"count" \| "alpha"` | `"count"` | Value sort order |
518
+ | `header` | `string` | -- | Optional heading above the list |
519
+ | `className` | `string` | -- | Additional CSS classes |
520
+ | `styled` | `boolean` | `true` | Enable built-in styles |
374
521
 
375
522
  ---
376
523
 
@@ -382,10 +529,10 @@ Page navigation with Previous/Next buttons and numbered pages.
382
529
  <Pagination />
383
530
  ```
384
531
 
385
- | Prop | Type | Default | Description |
386
- | ----------- | --------- | ------- | ------------------------ |
387
- | `className` | `string` | -- | Additional CSS classes |
388
- | `styled` | `boolean` | `true` | Enable built-in styles |
532
+ | Prop | Type | Default | Description |
533
+ | ----------- | --------- | ------- | ---------------------- |
534
+ | `className` | `string` | -- | Additional CSS classes |
535
+ | `styled` | `boolean` | `true` | Enable built-in styles |
389
536
 
390
537
  ---
391
538
 
@@ -400,11 +547,11 @@ Displays result count and processing time (e.g. "1,234 results in 3ms").
400
547
  } />
401
548
  ```
402
549
 
403
- | Prop | Type | Default | Description |
404
- | ----------- | ------------------------------------------------------- | ------- | ------------------------- |
405
- | `render` | `(stats: { totalHits; processingTimeMs; query }) => string` | -- | Custom render function |
406
- | `className` | `string` | -- | Additional CSS classes |
407
- | `styled` | `boolean` | `true` | Enable built-in styles |
550
+ | Prop | Type | Default | Description |
551
+ | ----------- | ----------------------------------------------------------- | ------- | ---------------------- |
552
+ | `render` | `(stats: { totalHits; processingTimeMs; query }) => string` | -- | Custom render function |
553
+ | `className` | `string` | -- | Additional CSS classes |
554
+ | `styled` | `boolean` | `true` | Enable built-in styles |
408
555
 
409
556
  ---
410
557
 
@@ -421,12 +568,12 @@ Sort-by select dropdown.
421
568
  />
422
569
  ```
423
570
 
424
- | Prop | Type | Default | Description |
425
- | ----------- | -------------- | ----------- | -------------------------- |
426
- | `items` | `SortByItem[]` | -- | Sort options |
427
- | `label` | `string` | `"Sort by"` | Accessibility label |
428
- | `className` | `string` | -- | Additional CSS classes |
429
- | `styled` | `boolean` | `true` | Enable built-in styles |
571
+ | Prop | Type | Default | Description |
572
+ | ----------- | -------------- | ----------- | ---------------------- |
573
+ | `items` | `SortByItem[]` | -- | Sort options |
574
+ | `label` | `string` | `"Sort by"` | Accessibility label |
575
+ | `className` | `string` | -- | Additional CSS classes |
576
+ | `styled` | `boolean` | `true` | Enable built-in styles |
430
577
 
431
578
  ---
432
579
 
@@ -438,13 +585,13 @@ Renders highlighted text for a hit attribute. Safe for use with `dangerouslySetI
438
585
  <Highlight hit={hit} attribute="title" />
439
586
  ```
440
587
 
441
- | Prop | Type | Default | Description |
442
- | ----------- | ------------------------- | -------- | ----------------------------- |
443
- | `hit` | `Record<string, unknown>` | -- | Hit object from `useHits()` |
444
- | `attribute` | `string` | -- | Attribute to highlight |
445
- | `tagName` | `keyof HTMLElementTagNameMap` | `"span"` | Wrapper element tag |
446
- | `className` | `string` | -- | Additional CSS classes |
447
- | `styled` | `boolean` | `true` | Enable built-in styles |
588
+ | Prop | Type | Default | Description |
589
+ | ----------- | ----------------------------- | -------- | --------------------------- |
590
+ | `hit` | `Record<string, unknown>` | -- | Hit object from `useHits()` |
591
+ | `attribute` | `string` | -- | Attribute to highlight |
592
+ | `tagName` | `keyof HTMLElementTagNameMap` | `"span"` | Wrapper element tag |
593
+ | `className` | `string` | -- | Additional CSS classes |
594
+ | `styled` | `boolean` | `true` | Enable built-in styles |
448
595
 
449
596
  ---
450
597
 
@@ -456,14 +603,14 @@ Min/max number inputs for numeric range filtering.
456
603
  <RangeInput attribute="price" header="Price" />
457
604
  ```
458
605
 
459
- | Prop | Type | Default | Description |
460
- | ---------------- | --------- | ------- | ----------------------------- |
461
- | `attribute` | `string` | -- | Numeric attribute to filter |
462
- | `header` | `string` | -- | Optional heading |
463
- | `minPlaceholder` | `string` | `"Min"` | Min input placeholder |
464
- | `maxPlaceholder` | `string` | `"Max"` | Max input placeholder |
465
- | `className` | `string` | -- | Additional CSS classes |
466
- | `styled` | `boolean` | `true` | Enable built-in styles |
606
+ | Prop | Type | Default | Description |
607
+ | ---------------- | --------- | ------- | --------------------------- |
608
+ | `attribute` | `string` | -- | Numeric attribute to filter |
609
+ | `header` | `string` | -- | Optional heading |
610
+ | `minPlaceholder` | `string` | `"Min"` | Min input placeholder |
611
+ | `maxPlaceholder` | `string` | `"Max"` | Max input placeholder |
612
+ | `className` | `string` | -- | Additional CSS classes |
613
+ | `styled` | `boolean` | `true` | Enable built-in styles |
467
614
 
468
615
  ---
469
616
 
@@ -475,30 +622,30 @@ Min/max number inputs for numeric range filtering.
475
622
  <PoweredBy />
476
623
  ```
477
624
 
478
- | Prop | Type | Default | Description |
479
- | ----------- | --------- | ------------------------ | ------------------------ |
480
- | `href` | `string` | `"https://headband.dev"` | Link URL |
481
- | `className` | `string` | -- | Additional CSS classes |
482
- | `styled` | `boolean` | `true` | Enable built-in styles |
625
+ | Prop | Type | Default | Description |
626
+ | ----------- | --------- | ------------------------ | ---------------------- |
627
+ | `href` | `string` | `"https://headband.dev"` | Link URL |
628
+ | `className` | `string` | -- | Additional CSS classes |
629
+ | `styled` | `boolean` | `true` | Enable built-in styles |
483
630
 
484
631
  ---
485
632
 
486
633
  ## Comparison to Algolia InstantSearch
487
634
 
488
- | Feature | @headband/react | react-instantsearch |
489
- | --------------------- | ---------------------------- | ---------------------------- |
490
- | Provider | `<HeadbandProvider>` | `<InstantSearch>` |
491
- | Search input | `<SearchBox>` / `useSearchBox` | `<SearchBox>` / `useSearchBox` |
492
- | Results | `<Hits>` / `useHits` | `<Hits>` / `useHits` |
493
- | Facets | `<RefinementList>` | `<RefinementList>` |
494
- | Pagination | `<Pagination>` | `<Pagination>` |
495
- | Stats | `<Stats>` | `<Stats>` |
496
- | Sort | `<SortBy>` | `<SortBy>` |
497
- | Range | `<RangeInput>` / `useRange` | `<RangeInput>` / `useRange` |
498
- | Highlighting | `<Highlight>` | `<Highlight>` |
499
- | Bundle size | ~33 KB (ESM, unminified) | ~120 KB+ |
500
- | Backend lock-in | None (Headband is engine-agnostic) | Algolia only |
501
- | Built-in styles | Tailwind (opt-out with `styled={false}`) | CSS classes |
635
+ | Feature | @headband/react | react-instantsearch |
636
+ | --------------- | ---------------------------------------- | ------------------------------ |
637
+ | Provider | `<HeadbandProvider>` | `<InstantSearch>` |
638
+ | Search input | `<SearchBox>` / `useSearchBox` | `<SearchBox>` / `useSearchBox` |
639
+ | Results | `<Hits>` / `useHits` | `<Hits>` / `useHits` |
640
+ | Facets | `<RefinementList>` | `<RefinementList>` |
641
+ | Pagination | `<Pagination>` | `<Pagination>` |
642
+ | Stats | `<Stats>` | `<Stats>` |
643
+ | Sort | `<SortBy>` | `<SortBy>` |
644
+ | Range | `<RangeInput>` / `useRange` | `<RangeInput>` / `useRange` |
645
+ | Highlighting | `<Highlight>` | `<Highlight>` |
646
+ | Bundle size | ~33 KB (ESM, unminified) | ~120 KB+ |
647
+ | Backend lock-in | None (Headband is engine-agnostic) | Algolia only |
648
+ | Built-in styles | Tailwind (opt-out with `styled={false}`) | CSS classes |
502
649
 
503
650
  ## TypeScript
504
651