@fern-api/search-widget
v0.5.6
Published
Fern AI-powered search widget for documentation sites
Readme
@fern-api/search-widget
Standalone, AI-powered documentation widgets that can be embedded in any React application:
SearchModal— keyword search + Ask AI in a centered modal overlay.AskAiChat— Ask AI only, as a floating chat card in a corner (no keyword-search UI).AskAiChat.Root/AskAiChat.Trigger/AskAiChat.Panel— the same chat with the trigger and the card placed separately, for a trigger inside a dropdown menu, popover or command palette.
All of them point at a published Fern docs site via a domain prop.
Installation
npm install @fern-api/search-widget react react-domOr with pnpm:
pnpm add @fern-api/search-widget react react-domPeer Dependencies
This package requires React 19 only:
{
"react": "^19",
"react-dom": "^19"
}All other dependencies are bundled - including state management (Jotai), data fetching (SWR), and AI SDK libraries. You only need to provide React 19.
Usage
Basic Setup
First, import the component and styles in your application:
import { SearchModal } from '@fern-api/search-widget';
import '@fern-api/search-widget/styles';Then use the SearchModal component:
function App() {
return (
<div>
<SearchModal
domain="https://your-docs-site.com"
lang="en"
>
🔍 Open Search
</SearchModal>
</div>
);
}Next.js and other server-rendering frameworks
Both components are client components — they use React context, state and effects. In the
Next.js App Router, that means the file rendering them needs the "use client" directive:
'use client';
import { AskAiChat } from '@fern-api/search-widget';
import '@fern-api/search-widget/styles';
export function AskAi() {
return <AskAiChat domain="https://your-docs-site.com" />;
}Without it you get TypeError: createContext only works in Client Components. A plain
import is otherwise all that's needed: the bundle is safe to evaluate on the server, so
next/dynamic and ssr: false are not required.
The Pages Router and non-Next setups (Vite, CRA, Remix) need nothing special; the widget is mounted client-side either way.
Props
The SearchModal component accepts the following props:
Required Props
domain(string): The documentation domain to search against (e.g.,"https://buildwithfern.com/learn")
Optional Props
All standard HTML button props are supported and will be forwarded to the trigger button:
lang(string): Language code for the search interface (e.g.,"en"). Defaults to"en"icon(React.ReactNode): Icon element to display in the buttonclassName: Additional CSS classes for the buttonstyle: Inline styles for the buttononClick: Additional click handler (runs before opening modal)disabled: Disable the buttonchildren: Button content (text, icons, etc.)
Styling the Button
You can style the trigger button in three ways:
1. Inline Styles
<SearchModal
domain="https://docs.example.com"
lang="en"
style={{
padding: '0.75rem 1.5rem',
fontSize: '1rem',
background: '#2563eb',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer'
}}
>
Search Documentation
</SearchModal>2. CSS Classes
<SearchModal
domain="https://docs.example.com"
lang="en"
className="my-search-button"
>
Search
</SearchModal>.my-search-button {
padding: 0.75rem 1.5rem;
background: #2563eb;
color: white;
border: none;
border-radius: 6px;
}3. Using the Default Class
The button has a fern-search-button class that you can target:
.fern-search-button {
padding: 0.75rem 1.5rem;
background: #2563eb;
color: white;
}Ask AI chat (no search UI)
AskAiChat gives you the Ask AI experience from a Fern docs site — streaming answers,
citations, new chat — with no keyword-search UI, as a floating card anchored to a corner
of the viewport. Below the md breakpoint (768px) it becomes a bottom drawer.
import { AskAiChat } from '@fern-api/search-widget';
import '@fern-api/search-widget/styles';
function App() {
return <AskAiChat domain="https://buildwithfern.com/learn" />;
}That's the whole integration: it ships its own launcher button, pinned bottom-right.
Because the answer lives on the docs site rather than in your app, citations point at your
docs domain and open in a new tab (target="_blank" rel="noreferrer"), so a reader never
leaves your page.
On the Next.js App Router, add "use client" to the file — see
Next.js and other server-rendering frameworks.
Props
AskAiChat takes the same props as SearchModal — all standard HTML button props are
forwarded to the trigger button — plus its own, below.
Required Props
domain(string): The docs domain to ask against (e.g."https://buildwithfern.com/learn")
Optional Props
trigger("floating" | "inline" | "none"):"floating"(default) renders the styled launcher pinned to a corner."inline"renders an unstyled button wherever you place it."none"renders no trigger, for opening the chat from your own UI — see Trigger modes. For a trigger inside a dropdown menu or popover, useAskAiChat.Root/AskAiChat.Trigger/AskAiChat.Panelopen(boolean): drive the chat yourself instead of letting the widget own its state. PassonOpenChangealongside it — see Opening it from your own UIdefaultOpen(boolean): the initial open state when uncontrolled. Ignored onceopenis passedonOpenChange((open: boolean) => void): fires whenever the chat wants to open or close, from any source — the trigger, the card's close button, Escape, orcmd/ctrl+/. Called in both controlled and uncontrolled modeplacement("bottom-right" | "bottom-left" | "top-right" | "top-left"): which corner the launcher and card anchor to. Defaults to"bottom-right"theme("inherit" | "light" | "dark" | "auto"): color scheme. Defaults to"inherit", which follows your site's own theme — see Dark modeaccentColor(string): your brand color, any CSS color. Defaults to the widget's neutral gray — see Accent colorlang(string): Language code for the UI. Defaults to"en"searchLocale(string): Overrides the locale used for retrieval. Defaults tolangicon(React.ReactNode): Replaces the trigger's sparkles iconchildren: Replaces the trigger'sAsk AIlabel — e.g.Search docs
Render one AskAiChat per page. Two instances give their launchers the same element id and the
same corner, so they stack: the one on top swallows every click and the other becomes
unreachable. If you need a trigger in more than one place — a nav bar, a help menu, and the foot
of an article — render a single AskAiChat.Root with one AskAiChat.Panel and as many
AskAiChat.Triggers as you like, of which at most one may be variant="floating". The
launcher is pinned to a viewport corner and carries a fixed id, so a second one has nowhere to go;
inline triggers have no such limit. See
A trigger in a dropdown menu, popover or command palette.
Trigger modes
trigger="floating" (default) — a styled pill in the corner, the pattern most sites
use for an AI assistant. Nothing to style; override the label and icon with children
and icon, or restyle it entirely via #fern-ask-ai-launcher.
trigger="inline" — for putting an "Ask AI" button in your own nav bar. Same content as
the launcher — the sparkle and an Ask AI label — in a <button> carrying a
fern-ask-ai-button class, wherever you place it in your tree, the same arrangement
SearchModal uses with fern-search-button:
<AskAiChat domain="https://docs.example.com" trigger="inline" className="my-nav-button" />Unlike the launcher it is unpainted — no surface, border, color or font of its own — so it
takes on your nav's styling; the sparkle sizes to 1em of the button's own text and draws in
currentColor (or your accentColor, if set). Restyle it via className, style, or the
class, and replace either half:
<AskAiChat domain="https://docs.example.com" trigger="inline">
Search docs
</AskAiChat>The card still anchors to placement in this mode; it just isn't offset to clear a
launcher.
trigger="none" — no trigger at all, just the chat, opened through open /
onOpenChange. See Opening it from your own UI.
A trigger in a dropdown menu, popover or command palette
Don't put AskAiChat inside a menu. It looks like it should work, and it renders nothing at
all:
{/* Broken. */}
<DropdownMenu.Item asChild>
<AskAiChat domain="https://docs.example.com" trigger="inline" />
</DropdownMenu.Item>Selecting the item closes the menu, and Radix — like Headless UI, and most menu libraries — unmounts its content when it closes. That takes the whole widget with it, chat included, before the chat ever renders. Nothing appears and nothing errors, which makes it a genuinely confusing failure.
Keeping the menu open instead (onSelect={(event) => event.preventDefault()}) trades it for a
worse one: Radix's modal menu sets pointer-events: none on <body> while it's open, and the
chat, portaled to <body>, inherits it. The card paints, looks correct, and ignores every click
and keystroke.
The fix is to let the menu close and split the widget in two — the trigger inside the menu, where it's fine to be unmounted, and the chat outside it, where nothing can unmount it:
import { AskAiChat } from "@fern-api/search-widget";
import "@fern-api/search-widget/styles";
function HelpMenu() {
return (
<AskAiChat.Root domain="https://docs.example.com">
<DropdownMenu.Root>
<DropdownMenu.Trigger>Help</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content>
<DropdownMenu.Item>Contact support</DropdownMenu.Item>
<DropdownMenu.Item asChild>
{/* Fern's button, as the menu item */}
<AskAiChat.Trigger />
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
{/* Outside the menu — this is the part that matters */}
<AskAiChat.Panel />
</AskAiChat.Root>
);
}AskAiChat.Rootholds the state, the search credentials and the theme, and renders no DOM of its own. It takesdomain,lang,searchLocale,placement,theme,accentColor, and the sameopen/defaultOpen/onOpenChangeasAskAiChat. Wrap it around both the menu and the panel.AskAiChat.Triggeris a real<button>, soasChildworks and Radix keeps its menu-item keyboard behavior on top of it. It carries the localizedAsk AIlabel, the accent-filled sparkle,aria-haspopup="dialog"andaria-controls— none of which you have to reproduce.variant="floating"gives you the corner launcher instead of an unpainted button;childrenandiconreplace the label and the glyph.AskAiChat.Panelis the chat. Render exactly one per root, outside any menu. It warns in the console if you forget it, since a trigger with no panel is otherwise silently dead.
This works with modal and non-modal menus alike, and the same shape covers a popover, a command palette, or a nav bar dropdown.
Opening it from your own UI
If the trigger isn't a button — a link, a list row, a palette entry you already have — set
trigger="none" on AskAiChat and drive open yourself:
import { AskAiChat } from "@fern-api/search-widget";
const [askAiOpen, setAskAiOpen] = useState(false);
<a href="#" onClick={() => setAskAiOpen(true)}>
<AskAiChat.Sparkles /> Still stuck? Ask AI
</a>
<AskAiChat
domain="https://docs.example.com"
trigger="none"
open={askAiOpen}
onOpenChange={setAskAiOpen}
/>open is controlled in the usual React sense: the widget renders what you give it, so
onOpenChange has to be wired back to your state or the chat's own controls — its close button,
Escape, cmd/ctrl + / — will appear to do nothing. Pass neither prop and the widget keeps
managing its own state; onOpenChange on its own works as a notification hook. ref stays null,
since there's no button of ours to attach it to, and the trigger's accessibility is yours:
aria-haspopup="dialog", plus aria-expanded if it's a button.
AskAiChat.Sparkles is the glyph the built-in trigger and the card's header wear, so your own trigger
reads as the same affordance. It sizes to 1em of the surrounding text and takes its color, and it
needs the stylesheet imported (which the widget requires anyway) to have a size at all.
Customizing appearance
Everything visual is a CSS custom property, following the same convention as
--fern-search-dialog-z-index. Set any of these on a parent (or :root):
| Variable | Controls | Default |
| --- | --- | --- |
| --fern-ask-ai-z-index | stacking of launcher, card and drawer | 2147483646 |
| --fern-ask-ai-offset | distance from the viewport corner | 20px |
| --fern-ask-ai-width | card width | 400px |
| --fern-ask-ai-height | card height (capped to the viewport) | min(640px, 70dvh) |
| --fern-ask-ai-expanded-width | card width when expanded | 480px |
| --fern-ask-ai-expanded-height | card height when expanded, capped to the viewport | min(900px, 85dvh) |
| --fern-ask-ai-radius | card + launcher corner radius | 12px |
| --fern-ask-ai-font-family | all widget text | ui-sans-serif stack |
| --fern-ask-ai-background | card, launcher and drawer surface | #fff |
| --fern-ask-ai-background-dark | the same surface in dark mode | Radix dark step 1 |
| --fern-ask-ai-text | primary text color, paired with the surface | --grayscale-a12 |
| --fern-ask-ai-icon | the sparkle icons | the accent below |
| --fern-ask-ai-accent | sparkles, send button, links in answers | gray, or accentColor when set |
Accent color
The widget is neutral by default — gray sparkles, a gray send button, gray links — because it renders inside your page, not on a Fern docs site. Give it your brand color with one prop:
<AskAiChat domain="https://docs.example.com" accentColor="#6b4eff" />One color covers both themes: a light and a dark variant are derived from it, the way a docs
site derives them from accent-primary in docs.yml. Your hue is kept, and its lightness is
held inside a window that reads against the surface behind it — so a navy brand color doesn't
disappear on the dark card, and a pale one doesn't wash out on the light one.
The equivalent in CSS, for hosts that would rather not pass a prop, is
--fern-ask-ai-accent. It is a single flat color for both themes, so unlike accentColor it
gets no per-theme adjustment; the sparkles keep their own hook, so they can differ:
:root {
--fern-ask-ai-accent: #6b4eff;
--fern-ask-ai-icon: #1ba32a;
}For finer control, every step of the accent scale the widget derives these from has its own
--widget-accent-* hook (--widget-accent-9, --widget-accent-a11, …).
To match the typography of your docs site, set the same font you configured in
docs.yml — --font-body is honored too, so values copied from a docs theme work as-is
(--fern-ask-ai-font-family takes precedence if both are set):
:root {
--fern-ask-ai-font-family: "GT-Planar", sans-serif;
}The widget can't pick this up automatically: a docs site gets its font from docs.yml,
and the widget's CSS is deliberately isolated from the host page, so it neither inherits
your fonts nor sees the docs site's.
:root {
--fern-ask-ai-width: 460px;
--fern-ask-ai-offset: 32px;
--fern-ask-ai-radius: 16px;
}The defaults sit above common host UIs (e.g. MUI app bars at 1100, modals at 1300). If
your site already has a chat widget in the bottom-right, move ours with placement.
Dark mode
By default the widget follows your site's theme. If you mark dark mode the way Tailwind and
next-themes do — a dark class (or data-theme="dark") on <html> or <body> — it is
picked up automatically and switches live when a visitor uses your theme toggle. Sites that
mark nothing render light.
The widget's styles stay isolated either way: your .dark class is read, never inherited,
so nothing of your CSS leaks in.
To pin a mode instead, or to follow the OS rather than your site:
<AskAiChat domain="https://docs.example.com" theme="dark" />
<AskAiChat domain="https://docs.example.com" theme="auto" />"auto" tracks prefers-color-scheme. It isn't the default because that setting describes
the visitor, not your site: a permanently light site shouldn't get a dark widget on it for
anyone browsing in dark mode.
Both modes derive from the same accent and grayscale scales, so the surface is usually the
only thing worth overriding. --fern-ask-ai-background-dark falls back to
--fern-ask-ai-background, so one brand surface still applies in both modes:
:root {
--fern-ask-ai-background: #fff;
--fern-ask-ai-background-dark: #0d0d0f;
}Content Security Policy
The widget talks to the docs origin, Algolia, and — when a reader rates an answer — Fern's
AI service, so connect-src needs:
connect-src <your-docs-origin> https://*.algolia.net https://*.algolianet.com https://fai.buildwithfern.com;Omitting fai.buildwithfern.com blocks feedback votes; the failure is logged to the
console rather than shown to the reader.
Complete Example
import { SearchModal } from '@fern-api/search-widget';
import '@fern-api/search-widget/styles';
function App() {
return (
<div className="app">
<header>
<h1>My Documentation</h1>
<SearchModal
domain="https://docs.mysite.com"
lang="en"
className="search-trigger"
>
🔍 Search Docs
</SearchModal>
</header>
</div>
);
}
export default App;Features
- AI-Powered Search: Natural language search with AI-generated responses
- Real-time Results: Fast search results as you type
- Code Highlighting: Syntax-highlighted code blocks in search results
- Keyboard Navigation: Full keyboard support for navigation
- Responsive Design: Works on desktop and mobile devices
- Customizable Styling: Style the trigger button to match your design
Browser Support
- Chrome/Edge (latest 2 versions)
- Firefox (latest 2 versions)
- Safari (latest 2 versions)
accentColor needs relative color syntax
accentColor derives its light and dark variants with CSS
relative color syntax
(oklch(from …)), which needs Chrome/Edge 119+, Safari 16.4+ or Firefox 128+.
Older browsers drop the declaration and fall back to the widget's neutral gray, so the widget stays perfectly legible — it just isn't branded. The degradation is silent, which matters mainly for Firefox ESR: a visitor there sees gray where you configured a brand color.
If you need the exact color everywhere instead, set --fern-ask-ai-accent — it is a flat
value with no color math, so it applies in any browser. The trade-off is that one value is
used for both themes, so pick something legible on your light and dark surfaces.
Troubleshooting
createContext only works in Client Components
The file rendering the widget needs the "use client" directive — see
Next.js and other server-rendering frameworks.
ReferenceError: document is not defined
You are on 0.4.0 or earlier — upgrade. The error came from the import itself, so no
change in your own code avoided it; the workaround was to defer the import with
dynamic(() => import(...), { ssr: false }), which is no longer needed.
Styles Not Loading
Make sure you import the CSS file:
import '@fern-api/search-widget/styles';Button Not Visible
The widget renders nothing at all until it has fetched a search key from your domain —
no button, no placeholder. So a wrong domain looks identical to the package not loading. The
one signal is a console error (Failed to fetch API key). Check:
- The
domainprop is a valid documentation site URL, including the protocol (https://docs.example.com, notdocs.example.com) ${domain}/api/fern-docs/search/v2/keyreturns 200 when you open it directly- The browser console for
Failed to fetch API keyor a CORS/DNS error
Modal Not Opening
Ensure React 19 peer dependencies are installed:
npm install react@19 react-dom@19If you see module resolution errors, verify that:
- You're using React 19 (not React 18 or earlier)
- Both
reactandreact-domare installed at the same version
What's Bundled
This package bundles all dependencies except React:
Included in bundle:
- ✅ State management (
jotai) - ✅ Data fetching (
swr) - ✅ AI SDK (
@ai-sdk/react,ai) - ✅ Search UI components
- ✅ All styling (Tailwind CSS + custom SCSS)
You provide:
- ⚠️ React 19 and ReactDOM 19
Bundle Size
What a visitor downloads on first paint:
- JavaScript: ~1.31 MiB minified, ~387 KiB gzipped
- CSS: ~842 KiB, ~43 KiB gzipped
Plus 121 lazily-loaded chunks fetched on demand, which is most of the 7 MiB you see on disk after install — syntax-highlighting grammars especially. Those are not part of the initial download.
SearchModal and AskAiChat ship from a single entry because they share almost the entire
bundle (search UI, AI SDK, Algolia, syntax highlighting), so importing only one of them does
not meaningfully shrink your bundle. The CSS is large because every selector is rewritten to
sit under the widget's container elements, which is what keeps widget styles from leaking into
the host page; that boilerplate is repetitive and compresses roughly 20:1.
Keeping it off your critical path
~387 KiB gzipped of JavaScript is a real cost, and none of it is needed until someone opens the chat. Load it lazily so it never blocks first paint or shows up in your Lighthouse score:
'use client';
import dynamic from 'next/dynamic';
import '@fern-api/search-widget/styles';
// Fetched after the page is interactive, not as part of the initial payload.
const AskAi = dynamic(() => import('@fern-api/search-widget').then((m) => m.AskAiChat));This is a performance choice, not a requirement — a plain import works, and ssr: false is
not needed either way (see
Next.js and other server-rendering frameworks).
The stylesheet is a separate entry, so importing it eagerly as above keeps the launcher styled
without pulling in the JavaScript.
License
Apache-2.0 - See LICENSE file in the package root.
Support
For issues and questions, please visit the Fern Platform repository.
