npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-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"
  • 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):

| 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:

  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.