@etoile-dev/react
v0.2.3
Published
Official React primitives for Étoile - Headless, composable search components
Maintainers
Readme
About
@etoile-dev/react provides headless, composable React components for search powered by Étoile.
Built on top of @etoile-dev/client, these primitives give you full control over styling while handling state, keyboard navigation, and accessibility.
Philosophy
- Headless-first — You control the appearance
- Composable — Build your own search UX
- Accessible — Full ARIA support and keyboard navigation
- No magic — Behavior is predictable and documented
- No opinions — Bring your own styles (or use our optional theme)
Install
npm i @etoile-dev/reactQuickstart
import { Search } from "@etoile-dev/react";
export default function App() {
return <Search apiKey="your-api-key" collections={["paintings"]} />;
}Composable Primitives
For full control, use the headless primitives:
import {
SearchRoot,
SearchInput,
SearchResults,
SearchResult,
} from "@etoile-dev/react";
export default function CustomSearch() {
return (
<SearchRoot
apiKey={process.env.ETOILE_API_KEY}
collections={["paintings"]}
limit={20}
>
<SearchInput placeholder="Search paintings..." className="search-input" />
<SearchResults className="results-list">
{(result) => (
<SearchResult className="result-item">
<h3>{result.title}</h3>
<p>{result.metadata.artist}</p>
<small>Score: {result.score.toFixed(2)}</small>
</SearchResult>
)}
</SearchResults>
</SearchRoot>
);
}Styling with data attributes
Each result automatically gets data-selected and data-index attributes:
.result-item {
padding: 1rem;
cursor: pointer;
}
.result-item[data-selected="true"] {
background: #f0f9ff;
border-left: 3px solid #0ea5e9;
}Default Theme
Import the optional theme for a polished, ready-to-use experience:
import "@etoile-dev/react/styles.css";
import { Search } from "@etoile-dev/react";
<Search apiKey="your-api-key" collections={["paintings"]} />That's it! The etoile-search class is applied automatically.
Dark Mode
Add dark to the className:
<Search apiKey="your-api-key" collections={["paintings"]} className="dark" />
// Or with SearchRoot
<SearchRoot apiKey="your-api-key" collections={["paintings"]} className="dark">
...
</SearchRoot>CSS Variables
Every value is customizable. Here are the key variables:
.etoile-search {
/* Colors */
--etoile-bg: #ffffff;
--etoile-border: #e4e4e7;
--etoile-text: #09090b;
--etoile-text-muted: #71717a;
--etoile-selected: #f4f4f5;
--etoile-ring: #18181b;
/* Sizing */
--etoile-radius: 12px;
--etoile-input-height: 44px;
--etoile-thumbnail-size: 40px;
--etoile-results-max-height: 300px;
/* Spacing */
--etoile-input-padding-x: 16px;
--etoile-result-gap: 16px;
--etoile-results-offset: 8px;
/* Typography */
--etoile-font-size-input: 15px;
--etoile-font-size-title: 14px;
/* Animation */
--etoile-transition: 150ms cubic-bezier(0.4, 0, 0.2, 1);
}See styles.css for the complete list of 40+ variables.
Headless hook
For complete control, use the useSearch hook:
import { useSearch } from "@etoile-dev/react";
function MyCustomSearch() {
const { query, setQuery, results, isLoading } = useSearch({
apiKey: "your-api-key",
collections: ["paintings"],
});
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search paintings..."
/>
{isLoading && <p>Loading...</p>}
<ul>
{results.map((result) => (
<li key={result.external_id}>{result.title}</li>
))}
</ul>
</div>
);
}API
<Search>
Convenience component that composes all primitives.
| Prop | Type | Required | Default |
|----------------|-----------------------------------------------|----------|---------|
| apiKey | string | ✓ | |
| collections | string[] | ✓ | |
| limit | number | | 10 |
| debounceMs | number | | 100 |
| renderResult | (result: SearchResultData) => React.ReactNode | | |
<SearchRoot>
Context provider that manages search state and keyboard navigation.
| Prop | Type | Required | Default |
|---------------|-------------------|----------|---------|
| apiKey | string | ✓ | |
| collections | string[] | ✓ | |
| limit | number | | 10 |
| debounceMs | number | | 100 |
| autoFocus | boolean | | false |
| children | React.ReactNode | ✓ | |
<SearchInput>
Controlled input with ARIA combobox role.
| Prop | Type |
|---------------|----------|
| placeholder | string |
| className | string |
Keyboard shortcuts:
ArrowUp/ArrowDown— Navigate resultsEnter— Select active resultEscape— Close results (press again to clear)
<SearchResults>
Results container with ARIA listbox role.
| Prop | Type | Required |
|-------------|-----------------------------------------------|----------|
| className | string | |
| children | (result: SearchResultData) => React.ReactNode | ✓ |
<SearchResult>
Individual result with ARIA option role.
| Prop | Type | Required |
|-------------|-------------------|----------|
| className | string | |
| children | React.ReactNode | ✓ |
Data attributes:
data-selected="true" | "false"— Active statedata-index="number"— Result position
<SearchResultThumbnail>
Thumbnail image that auto-detects from metadata.thumbnailUrl.
| Prop | Type | Required | Default |
|-------------|----------|----------|-------------------------------|
| src | string | | metadata.thumbnailUrl |
| alt | string | | result.title |
| size | number | | 40 |
| className | string | | |
<SearchIcon>
Built-in search magnifying glass SVG icon.
| Prop | Type | Required | Default |
|-------------|----------|----------|---------|
| size | number | | 18 |
| className | string | | |
<SearchKbd>
Keyboard shortcut badge.
| Prop | Type | Required | Default |
|-------------|-------------------|----------|---------|
| children | React.ReactNode | | ⌘K |
| className | string | | etoile-kbd |
<SearchKbd /> // Shows "⌘K"
<SearchKbd>/</SearchKbd> // Shows "/"useSearch(options)
Headless hook for complete control.
Options:
| Field | Type | Required | Default |
|---------------|------------|----------|---------|
| apiKey | string | ✓ | |
| collections | string[] | ✓ | |
| limit | number | | 10 |
| debounceMs | number | | 100 |
Returns:
| Field | Type |
|--------------------|----------------------------|
| query | string |
| setQuery | (q: string) => void |
| results | SearchResultData[] |
| isLoading | boolean |
| selectedIndex | number |
| setSelectedIndex | (i: number) => void |
| clear | () => void |
Types
type SearchResultData = {
external_id: string;
title: string;
collection: string;
score: number;
content?: string;
metadata: Record<string, unknown>;
};Why @etoile-dev/react?
- Radix / shadcn-style primitives — Composable and unstyled
- Accessibility built-in — ARIA combobox, keyboard navigation, focus management, click-outside dismiss
- Behavior, not appearance — You own the design
- TypeScript-first — Full type safety
- Zero dependencies — Only React and @etoile-dev/client
