@fern-api/search-widget

中文文档
0.6.0发布于 2026/10/03Apache-2.0TypeScriptES Module近 30 天下载 8762

Fern AI-powered search widget for documentation sites

原始使用文档
文档目录31 节
  1. Installation
  2. Peer Dependencies
  3. Usage
  4. Basic Setup
  5. Next.js and other server-rendering frameworks
  6. Props
  7. Styling the Button
  8. Ask AI chat (no search UI)
  9. Props
  10. Trigger modes
  11. A trigger in a dropdown menu, popover or command palette
  12. Opening it from your own UI
  13. Customizing appearance
  14. Accent color
  15. Dark mode
  16. Content Security Policy
  17. Complete Example
  18. Features
  19. Browser Support
  20. accentColor needs relative color syntax
  21. Troubleshooting
  22. createContext only works in Client Components
  23. ReferenceError: document is not defined
  24. Styles Not Loading
  25. Button Not Visible
  26. Modal Not Opening
  27. What's Bundled
  28. Bundle Size
  29. Keeping it off your critical path
  30. License
  31. Support

@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-dom

Or with pnpm:

pnpm add @fern-api/search-widget react react-dom

Peer 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 button
  • className: Additional CSS classes for the button
  • style: Inline styles for the button
  • onClick: Additional click handler (runs before opening modal)
  • disabled: Disable the button
  • children: 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, use AskAiChat.Root / AskAiChat.Trigger / AskAiChat.Panel
  • open (boolean): drive the chat yourself instead of letting the widget own its state. Pass onOpenChange alongside it — see Opening it from your own UI
  • defaultOpen (boolean): the initial open state when uncontrolled. Ignored once open is passed
  • onOpenChange ((open: boolean) => void): fires whenever the chat wants to open or close, from any source — the trigger, the card's close button, Escape, or cmd/ctrl + /. Called in both controlled and uncontrolled mode
  • placement ("bottom-right" | "bottom-left" | "top-right" | "top-left"): which corner the launcher and card anchor to. Defaults to "bottom-right"
  • draggableCard (boolean): whether visitors can drag the card anywhere in the viewport by its header or by the launcher, which moves along with it; placement still decides where it starts, neither can be dragged off-screen, and the card opens on whichever side of the launcher has room for it. The launcher's sparkle turns into a grip after the pointer rests on it (or, with data-drag-hint="tab" on the launcher, a grip tab slides out of its side on hover instead). Defaults to true. The drawer below md is never draggable
  • theme ("inherit" | "light" | "dark" | "auto"): color scheme. Defaults to "inherit", which follows your site's own theme — see Dark mode
  • accentColor (string): your brand color, any CSS color. Defaults to the widget's neutral gray — see Accent color
  • lang (string): Language code for the UI. Defaults to "en"
  • searchLocale (string): Overrides the locale used for retrieval. Defaults to lang
  • icon (React.ReactNode): Replaces the trigger's sparkles icon
  • children: Replaces the trigger's Ask AI label — 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.Root holds the state, the search credentials and the theme, and renders no DOM of its own. It takes domain, lang, searchLocale, placement, theme, accentColor, and the same open / defaultOpen / onOpenChange as AskAiChat. Wrap it around both the menu and the panel.
  • AskAiChat.Trigger is a real <button>, so asChild works and Radix keeps its menu-item keyboard behavior on top of it. It carries the localized Ask AI label, the accent-filled sparkle, aria-haspopup="dialog" and aria-controls — none of which you have to reproduce. variant="floating" gives you the corner launcher instead of an unpainted button; children and icon replace the label and the glyph.
  • AskAiChat.Panel is 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):

VariableControlsDefault
--fern-ask-ai-z-indexstacking of launcher, card and drawer2147483646
--fern-ask-ai-offsetdistance from the viewport corner20px
--fern-ask-ai-widthcard width400px
--fern-ask-ai-heightcard height (capped to the viewport)min(640px, 70dvh)
--fern-ask-ai-expanded-widthcard width when expanded480px
--fern-ask-ai-expanded-heightcard height when expanded, capped to the viewportmin(900px, 85dvh)
--fern-ask-ai-radiuscard + launcher corner radius12px
--fern-ask-ai-font-familyall widget textui-sans-serif stack
--fern-ask-ai-backgroundcard, launcher and drawer surface#fff
--fern-ask-ai-background-darkthe same surface in dark modeRadix dark step 1
--fern-ask-ai-textprimary text color, paired with the surface--grayscale-a12
--fern-ask-ai-iconthe sparkle iconsthe accent below
--fern-ask-ai-accentsparkles, send button, links in answersgray, 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:

  1. The domain prop is a valid documentation site URL, including the protocol (https://docs.example.com, not docs.example.com)
  2. ${domain}/api/fern-docs/search/v2/key returns 200 when you open it directly
  3. The browser console for Failed to fetch API key or a CORS/DNS error

Modal Not Opening

Ensure React 19 peer dependencies are installed:

npm install react@19 react-dom@19

If you see module resolution errors, verify that:

  • You're using React 19 (not React 18 or earlier)
  • Both react and react-dom are 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.

版本与发布记录

@fern-api/search-widget 共发布 26 个版本,首次发布于 2025/12/18;过去 12 个月发布了 26 个版本。

版本发布日期距今
0.6.0latest2026/10/031 个月前
0.5.62026/09/191 个月前
0.5.52026/09/181 个月前
0.5.42026/08/292 个月前
0.5.32026/08/292 个月前
0.5.22026/08/262 个月前

关于 @fern-api/search-widget 的常见问题

@fern-api/search-widget 是什么?

Fern AI-powered search widget for documentation sites

如何安装 @fern-api/search-widget?

在项目目录运行 npm install @fern-api/search-widget,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:react(^19)、react-dom(^19)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@fern-api/search-widget 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@fern-api/search-widget 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@fern-api/search-widget 可以商用吗?

@fern-api/search-widget 使用 Apache-2.0 许可。宽松许可:允许商业使用、修改和再分发,并附带明确的专利授权;分发时需保留许可与 NOTICE 文件,修改过的文件要注明改动。以上为概括,不构成法律意见,请以许可原文为准。

@fern-api/search-widget 的下载量有多少?

根据 npm 官方下载统计,@fern-api/search-widget 在 2026/10/15 – 2026/11/13 这 30 天内被下载 8,762 次,日均约 292 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@fern-api/search-widget 还在维护吗?

最新版本 0.6.0 发布于 2026/10/03(1 个月前);至今共发布 26 个版本,其中过去 12 个月发布了 26 个;首次发布于 2025/12/18。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。