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

@kookee/react

v1.20.0

Published

Official Kookee React SDK - hooks, server-rendered blog, changelog, help center, roadmap, feedback, status page and announcement components, and an embeddable AI support chat

Readme

@kookee/react

Official React SDK for Kookee — hooks and ready-made components for your blog, changelog, help center, feedback board, status page and announcements, plus an embeddable AI support chat.

Features

  • Hooks for every module — blog, pages, help center, changelog, announcements and feedback, plus generic hooks that read any of them by type
  • Headless — hooks return data, you own the markup
  • Components — server-rendered blog, changelog, help center, roadmap, read-only feedback, status page, and announcement UI in the hosted portal's look, with a table of contents, pagination, reactions, a what's-new badge, and keyboard-driven help search, themed from one CSS rule and customizable down to every element
  • Server rendering — initialData hands framework-loader results to any hook
  • No query-layer dependency — the root export has no runtime dependencies
  • Tree-shakeable — importing one hook costs ~2 kB, not the whole library
  • Chat widget — opinionated, styled, on its own subpath so it costs nothing unless used

Installation

npm install @kookee/react @kookee/sdk

@kookee/sdk 1.19.0 or later is a peer dependency, as are react and react-dom (18 or 19).

Quick Start

Wrap your app once:

import { KookeeProvider } from '@kookee/react';

<KookeeProvider config={{ apiKey: 'your-api-key' }} locale="en">
  <App />
</KookeeProvider>;

Then read data anywhere:

import { useBlogPosts } from '@kookee/react';

function Blog() {
  const { data, isLoading, error, loadMore, hasMore } = useBlogPosts({ limit: 10 });

  if (isLoading) return <Spinner />;
  if (error) return <Error error={error} />;

  return (
    <>
      {data.map((post) => (
        <article key={post.id}>{post.title}</article>
      ))}
      {hasMore && <button onClick={loadMore}>Load more</button>}
    </>
  );
}

A public apiKey

Use the project's public apiKey. A key in a client bundle ships to every visitor, so restrict it to your domains with the project's allowed origins; the script-tag widget below always uses one.

projectId can take its place only while the project's hosted portal is enabled. Without the portal, the API refuses a request that carries only a project id with 403 PORTAL_NOT_ENABLED.

Provider

<KookeeProvider config={{ apiKey, baseUrl }} locale="en">

| Prop | Type | Description | | -------- | -------------- | --------------------------------------------------------------------- | | config | KookeeConfig | apiKey (or projectId), optional baseUrl, timeoutMs, getPath | | locale | string | Default locale for every hook; per-call params override it |

With getPath, the entry links in the bodies the hooks return arrive resolved, as the SDK's "Entry links" describes. An inline function is fine: the client keeps the latest one without being rebuilt.

The client is created once and shared. useKookee() returns it directly — the escape hatch for anything the hooks do not cover:

const kookee = useKookee();
await kookee.entries.react(postId, { reactionType: 'heart', action: 'add' });

Hooks

Query hooks return { data, isLoading, error, refetch }, with data null until it arrives. List hooks add { loadMore, hasMore, isLoadingMore, total }. Collection hooks — useBlogTags, useHelpCategories, useHelpSearch, useEntryTags, useEntryCategories, useFeedbackColumns, useFeedbackTopContributors, useConfigList — return the same shape but with data as an array that starts empty and is never null, so do not guard them for null. useReact is the exception: it is a mutation and returns { react, isPending, error }.

| Module | Hooks | | ------------- | -------------------------------------------------------------------------------------------------------------------- | | Blog | useBlogPosts · useBlogPost · useBlogPostById · useBlogTags | | Pages | usePages · usePage · usePageById | | Help | useHelpArticles · useHelpArticle · useHelpArticleById · useHelpCategories · useHelpSearch | | Changelog | useChangelog · useChangelogEntry · useChangelogEntryById | | Announcements | useAnnouncements · useAnnouncement | | Feedback | useFeedbackPosts · useFeedbackPost · useFeedbackColumns · useFeedbackTopContributors · useFeedbackComments | | Custom types | useEntries · useEntry · useEntryById · useEntryTags · useEntryCategories | | Status | useStatus · useStatusIncident | | Shared | useComments · useReact · useConfig · useConfigList · useTranslations · useTranslationsById |

Detail hooks stay idle until their argument resolves

Pass undefined while a route param is still loading and the hook makes no request:

const { slug } = useParams();
const { data: post, isLoading } = useBlogPost(slug); // idle while slug is undefined

Generic entry hooks

useEntries and useEntry read any entry type by its slug: blog, page, help_article, changelog, or announcement. type is required:

const { data } = useEntries({ type: 'changelog', limit: 20 });
const { data: release } = useEntry(slug, { type: 'changelog' });

Search

useHelpSearch debounces internally, so it is safe to wire straight to an input:

const [query, setQuery] = useState('');
const { data: results, isLoading } = useHelpSearch(query);

Status

useStatus reads the project's status page (kookee.status.get() in @kookee/sdk): its indicator, its components with their 90-day uptime, the open incidents and maintenance, and the incidents of the last 90 days. It takes no locale, since incidents are written in one language:

const { data: status } = useStatus({ refreshSeconds: 60 });

status?.indicator; // 'operational' | 'minor' | 'major' | 'maintenance'
status?.activeIncidents; // open incidents, newest first, each with its latest update

useStatusIncident(id) reads one published incident or maintenance with its whole timeline (kookee.status.getIncident(id)), idle while id is undefined. Render either with the status page components.

Keeping a page current

Every hook that reads one value (useStatus, useBlogPost, …) takes refreshSeconds: it refetches that often while the tab is visible, and once more when a hidden tab is shown again after that long. The API caches these reads for 30 seconds, so the interval is 30 seconds at the least. A 429 doubles the wait, up to 10 minutes, and a 401 or 403 stops it. A refetch that fails keeps the data it had, with error set.

Reactions

useReact is a mutation, not a query:

const { react, isPending } = useReact(post.id);

<button disabled={isPending} onClick={() => react({ reactionType: 'heart', action: 'add' })}>
  👍 {post.reactions.like ?? 0}
</button>;

react() resolves with the updated reactions map, so you can use its return value instead of refetching.

It does not update the entry in place — the hook holding that entry keeps its old counts until you use the returned value or call its refetch(). For reaction buttons that remember what the visitor pressed, keep counts optimistic, and need no provider, use Reactions or useReactions from @kookee/react/entry/client (Reactions).

Rendering content

import { EntryContent } from '@kookee/react';

<EntryContent entry={post} className="kookee-typography" />;

In a React Server Component, import it from @kookee/react/entry instead: the package root is a client module, so Next.js would send the body to the browser a second time.

EntryContent accepts detail entries only. List responses do not include contentHtml, so passing a list item is a compile error rather than a blank page:

const { data: posts } = useBlogPosts();
<EntryContent entry={posts[0]} />; // ✗ Type error — list items have no body

const { data: post } = useBlogPost(slug);
<EntryContent entry={post} />; // ✓

It renders the HTML your Kookee project produced, as-is, and has no DOM dependencies — the same markup on the server and the client. Content that originates outside Kookee should be sanitised before it reaches the CMS.

The container always carries the kookee-entry-content class (your className is merged after it) — the class the stylesheets are scoped to. @kookee/react/content.css, the components' stylesheet, gives a body with the kookee-typography class its typography: headings, links, lists, quotes, code, images, rules, and tables, at 16 px. The bodies the components render carry it already (14.5 px inside changelog entries, help articles, and feedback); a standalone EntryContent takes it when you add the class, and keeps your own styles, such as Tailwind's prose, otherwise. For syntax-highlighted code blocks and file attachment chips, add the SDK's content stylesheet:

import '@kookee/sdk/styles/content.css';

In a Tailwind v4 app, import it into a cascade layer instead, so your prose-code: utilities keep winning over the stylesheet's defaults (an unlayered import would beat any layered utility regardless of specificity):

@import '@kookee/sdk/styles/content.css' layer(components);

The flip side of the layered import: prose's own pre styles and prose-code: utilities also reach elements inside code blocks, and the layered stylesheet can't defend against them. If you render entry content inside prose containers, add this unlayered guard once:

.kookee-entry-content .kookee-code-block pre {
  margin: 0;
  border-radius: 0;
}

.kookee-entry-content .kookee-code-block pre code {
  background: none;
  padding: 0;
  border: none;
  border-radius: 0;
  font-size: 0.875rem;
}

Without the components' stylesheet, and without a typography system of your own (no Tailwind prose), add @kookee/sdk/styles/typography.css for baseline headings, lists, tables and images — scoped to the same class, inheriting your page's font and colors.

The content stylesheet is themeable through --kookee-code-*, --kookee-file-chip-* and --kookee-mention-* custom properties — set them on :root to match your design system (required for dark mode, whose chip fallbacks are light-theme colors). See the @kookee/sdk README for the full list.

Reading custom fields

Changelog type, version, link and any field you defined yourself live in entry.fields, not as top-level properties. Read them with the SDK's helpers:

import { fieldOptionKey, fieldStringValue } from '@kookee/sdk';

const type = fieldOptionKey(entry.fields, 'changelogType'); // 'feature' | 'fix' | ...
const version = fieldStringValue(entry.fields, 'version'); // '2.1.0'

Both return undefined when the field is absent, so entry types without them need no special-casing.

Server rendering

Hooks fetch in an effect, which does not run on the server. Fetch in your framework's loader and pass the result as initialData — the hook renders it immediately and skips its first request:

// TanStack Start
export const Route = createFileRoute('/blog')({
  loader: () => kookee.blog.list({ limit: 10 }),
  component: Blog,
});

function Blog() {
  const loaderData = Route.useLoaderData();
  const { data, loadMore } = useBlogPosts({ limit: 10 }, { initialData: loaderData });
  // Server-renders from loaderData; loadMore still works on the client.
}

initialData covers the parameters it was fetched with — changing them fetches normally.

@kookee/react and @kookee/react/chat are client components, marked 'use client'. A Next.js Server Component file can import KookeeProvider, the hooks, and the chat directly, but the hooks only run in client components below it. For markup rendered entirely on the server, fetch with @kookee/sdk and use the components below, which take their data as props (EntryContent included, from @kookee/react/entry).

Components

Ready-made blog, changelog, help center, roadmap, feedback, status page, and announcement UI, in the look of Kookee's hosted portal. The components take data you fetched with @kookee/sdk, render real markup on the server, and need no provider. A page built from them is reduced to your data, your links, your labels, and your own page chrome; the look comes from one stylesheet and one rule of your tokens.

Every area renders from @kookee/react/<area>, on the server (and on the client). The areas with interactive pieces, entry, changelog, help, and announcements, also have @kookee/react/<area>/client, which holds them, marked 'use client', each with a headless hook for building your own. Use our markup, or the same logic under yours.

| Import | Runs on | Exports | | --- | --- | --- | | @kookee/react/entry | server and client | EntryContent, TableOfContents, extractHeadings, Pagination, getPaginationItems, EmptyState, Highlight, snippetAround | | @kookee/react/entry/client | client | Reactions, useReactions, TrackedTableOfContents, useActiveHeading | | @kookee/react/blog | server and client | BlogPostList, BlogPostCard, BlogPost, BlogTagFilter, BlogCategoryNav, BlogPostListSkeleton, and parts | | @kookee/react/changelog | server and client | ChangelogList, ChangelogItem, ChangelogArticle, ChangelogTypeFilter, ChangelogListSkeleton, and parts | | @kookee/react/changelog/client | client | WhatsNew, useWhatsNew | | @kookee/react/help | server and client | HelpCategoryGrid, HelpCategoryCard, HelpArticleList, HelpArticleCard, HelpArticle, HelpCategoryHeader, HelpCategoryNav, HelpArticleNav, HelpBreadcrumbs, HelpCategoryGridSkeleton, HelpArticleListSkeleton, and parts | | @kookee/react/help/client | client | HelpSearchBox, useHelpSearchBox, HelpfulVote | | @kookee/react/roadmap | server and client | Roadmap, RoadmapColumn, RoadmapPostCard, RoadmapCategoryFilter, groupRoadmapPosts, RoadmapSkeleton, and parts | | @kookee/react/feedback | server and client | FeedbackPostList, FeedbackPostRow, FeedbackPostListSkeleton, FeedbackSortFilter, FeedbackPostHeader, FeedbackPostContent, FeedbackPostMeta, FeedbackCommentList, FeedbackPostSkeleton, FeedbackTopContributors | | @kookee/react/status | server and client | StatusHeadline, StatusOverview, StatusIncidentArticle, StatusBadge | | @kookee/react/announcements | server and client | AnnouncementPill, AnnouncementList, AnnouncementArticle | | @kookee/react/announcements/client | client | useDismissedAnnouncements | | @kookee/react/content.css | — | the stylesheet for all of them |

Tested with React 19 in the Next.js App Router (Server Components), TanStack Start (server render plus hydration), and a Vite single-page app, and with React 18 for server rendering and hydration.

Setup

Import the two stylesheets once. The first styles the components, the second the code blocks and file chips inside entry bodies:

import '@kookee/react/content.css';
import '@kookee/sdk/styles/content.css';

Leave the first one out and the components render unstyled, with every class name and data-* attribute still in place. The first also styles the entry bodies inside the components (BlogPost, ChangelogArticle, HelpArticle, AnnouncementArticle, and the feedback posts and comments), so drop the prose classes you gave them. A standalone EntryContent keeps yours, or takes the package's with className="kookee-typography".

Then map your design tokens once, as Theming shows. The reference pages show every portal page built from the components alone.

Tailwind v4. Import both into the components layer, after Tailwind, so the utilities you pass through className and classNames win over the defaults:

@import 'tailwindcss';
@import '@kookee/sdk/styles/content.css' layer(components);
@import '@kookee/react/content.css' layer(components);

The order matters. Imported before tailwindcss, the components layer is declared below Preflight, whose h1–h6, ol, and img rules then override the components.

BlogPostList, ChangelogList, HelpCategoryGrid, HelpArticleList, and FeedbackPostList are size containers (container-type: inline-size), which take no width from their content. Give them a definite width: a block, a stretched flex child, or a 1fr grid track. As a shrink-to-fit child (a flex row item without a width, w-fit, an auto track) they collapse to zero.

Next.js (App Router)

// app/changelog/page.tsx — a Server Component
import { Kookee } from '@kookee/sdk';
import { ChangelogList } from '@kookee/react/changelog';

const kookee = new Kookee({ apiKey: process.env.KOOKEE_API_KEY });

export default async function ChangelogPage() {
  const { data } = await kookee.changelog.list({ limit: 20 });
  return <ChangelogList entries={data} href="/changelog/{slug}" />;
}

A Server Component can pass functions (href functions, renderLink, formatDate, renderImage, renderItem) to the server-rendered components. It cannot pass functions to the client components: give those string hrefs, or render them from your own 'use client' file to use function props such as onReact.

Next.js serializes every prop of a client component into the page, so the client components take only what they need: Reactions and HelpfulVote an entry id and its counts, TrackedTableOfContents the headings, never a whole entry:

// app/blog/[slug]/page.tsx — a Server Component
import { EntryContent, extractHeadings } from '@kookee/react/entry';
import { Reactions, TrackedTableOfContents } from '@kookee/react/entry/client';

export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
  const post = await kookee.blog.getBySlug((await params).slug);
  return (
    <>
      <TrackedTableOfContents headings={extractHeadings(post.contentHtml)} offset={64} />
      <EntryContent entry={post} className="kookee-typography" />
      <Reactions entryId={post.id} reactions={post.reactions} config={{ apiKey: process.env.KOOKEE_API_KEY }} />
    </>
  );
}

TanStack Start

TanStack Router's to is a pathname: a query string inside it is dropped. Split the href the components resolve into to, search, and hash before handing it to <Link>, so pagination, filter, and roadmap links keep theirs:

  • Parse the search the way the router does (router.options.parseSearch), so ?page=2 stays a number.
  • Resolve a query-only href (?page=2) against the current location, not the site root.
  • Render a plain <a> for another origin, such as the hosted portal links a roadmap can point to.
  • Match active links exactly: by default TanStack marks every link whose path and search the current URL contains as aria-current="page", so "All" would look selected beside the chosen filter.
import { Link, useRouter } from '@tanstack/react-router';
import type { RenderLink, RenderLinkProps } from '@kookee/react/entry';

const LOCAL_ORIGIN = 'http://app.invalid';

function RouterLink({ href, ...props }: RenderLinkProps) {
  const router = useRouter();
  const url = new URL(href, `${LOCAL_ORIGIN}${router.latestLocation.href}`);
  if (url.origin !== LOCAL_ORIGIN) return <a href={href} {...props} />;
  return (
    <Link
      {...props}
      to={url.pathname}
      search={router.options.parseSearch(url.search)}
      hash={url.hash.slice(1) || undefined}
      activeOptions={{ exact: true }}
    />
  );
}

export const renderLink: RenderLink = (props) => <RouterLink {...props} />;
export const Route = createFileRoute('/changelog/')({
  loader: () => kookee.changelog.list({ limit: 20 }),
  component: function Changelog() {
    const { data } = Route.useLoaderData();
    return <ChangelogList entries={data} href="/changelog/{slug}" renderLink={renderLink} />;
  },
});

Links

Every href is a template or a function:

<BlogPostList posts={posts} href="/blog/{slug}" tagHref="/blog/tag/{tagSlug}" />
<BlogPostList posts={posts} href={(post) => `/${post.locale}/blog/${post.slug}`} />

| Prop | Placeholders | | --- | --- | | entry href (blog, changelog, help articles, what's new, search results) | {slug}, {id}, {categorySlug} | | tagHref, BlogTagFilter href | {tagSlug} | | HelpArticleNav href | {slug}, {id}, {categorySlug} | | FeedbackPostList, FeedbackPostRow href | {id} | | FeedbackSortFilter href | {sort} | | HelpCategoryGrid, HelpCategoryCard, HelpCategoryNav, BlogCategoryNav href, HelpBreadcrumbs categoryHref | {categorySlug}, {categoryId} | | ChangelogTypeFilter href | {type} | | RoadmapCategoryFilter href | {category} | | Pagination href | {page} | | roadmap href | {id} | | StatusOverview incidentHref | {id} |

Values are URL-encoded. When a placeholder has no value — a post without a slug, a help article without a category — or the function returns null, the item renders without a link; cards and changelog items also get data-has-link="false".

renderLink replaces the default <a> with your router's link. It receives href, className, children, and where they apply aria-current, aria-label, and rel; spread them onto your link.

Lists, filters, and pagination

Without items, a list renders an EmptyState: an icon tile and its empty label. Every list takes empty, shown instead, for example when filters match nothing:

<BlogPostList
  posts={posts}
  href="/blog/{slug}"
  empty={
    <EmptyState
      title="No posts match"
      description="Try other words, or another tag."
      action={<a href="/blog">Clear filters</a>}
    />
  }
/>

EmptyState comes from @kookee/react/entry; icon replaces its tray icon, and null drops it.

BlogTagFilter, BlogCategoryNav, ChangelogTypeFilter, RoadmapCategoryFilter, FeedbackSortFilter, and Pagination are links, not state: the page renders again for the new URL, so they work from a Server Component and without JavaScript.

<BlogTagFilter tags={tags} activeTagSlug={tag} href="/blog?tag={tagSlug}" allHref="/blog" showCounts />
<BlogCategoryNav categories={categories} activeCategorySlug={category} href="/blog?category={categorySlug}" showCounts />
<ChangelogTypeFilter activeType={type} href="/changelog?type={type}" allHref="/changelog" />
<RoadmapCategoryFilter activeCategory={category} href="/roadmap?category={category}" allHref="/roadmap" />
<Pagination page={page} totalPages={totalPages} href={(page) => (page === 1 ? '/blog' : `/blog?page=${page}`)} />

BlogTagFilter leaves out the tags with no posts, except the active one (hideEmpty, on by default; hideEmpty={false} lists them all). Read the categories for BlogCategoryNav with kookee.blog.categories({ locale }), or useEntryCategories('blog') in a client component, and the posts of one with kookee.blog.list({ category }). It shows each category's icon and name, its number of posts with showCounts, and its description, when it has one, with showDescriptions; it has no "All" link, so link your blog index next to it.

With many tags, limit shows only the first ones of those it lists. The tags past it still render, with hidden, so crawlers see every link, and the active tag is never hidden. expanded shows them all. In a client component, hold expanded in state and pass onExpandedChange: the filter renders its own toggle button, labelled showMore ("Show 2 more") and showLess:

const [expanded, setExpanded] = useState(false);

<BlogTagFilter
  tags={tags}
  href="/blog?tag={tagSlug}"
  allHref="/blog"
  limit={10}
  expanded={expanded}
  onExpandedChange={setExpanded}
/>;

A server-only site drives expanded from a URL parameter and passes toggle, which replaces the button with a last item of its own, such as a link: toggle={(hiddenCount) => …}. A classNames.item that sets display overrides hidden and shows every tag; Tailwind's Preflight keeps [hidden] hidden.

Pagination shows the first page, the last, and one either side of the current one, with an ellipsis for each gap (a gap of a single page shows that page); getPaginationItems(page, totalPages) returns that list for your own markup. It renders nothing for a single page.

There is no search form component: a GET form is plain HTML, and it works the same way in every framework:

<form action="/blog" method="get" role="search">
  <input type="search" name="q" defaultValue={q} aria-label="Search posts" />
</form>

Loading states

BlogPostListSkeleton, ChangelogListSkeleton, HelpCategoryGridSkeleton, HelpArticleListSkeleton, RoadmapSkeleton, and FeedbackPostListSkeleton render their list's own classes with pulsing placeholders, so the page keeps its layout while it loads; FeedbackPostSkeleton does the same for a post. HelpArticleListSkeleton numbered is the ranked grid of a numbered list. They take count (3 by default), set aria-busy, and hide the placeholders from assistive technology. RoadmapSkeleton also takes the columns you already have, and shows their names above placeholder cards.

Table of contents

extractHeadings(contentHtml) returns the headings of an entry body that carry an id (Kookee gives every saved heading one), levels 1 to 3 by default. TableOfContents renders them as nested links, and nothing below two headings:

import { extractHeadings, TableOfContents } from '@kookee/react/entry';

<TableOfContents headings={extractHeadings(post.contentHtml)} />;

TrackedTableOfContents from @kookee/react/entry/client also marks the heading being read (aria-current="location" and data-active). It takes headings, or the body itself as html in a client-side app (in a React Server Component pass headings, or the whole body is sent to the browser). Set offset to the height of a sticky header, give your headings a scroll-margin-top of at most that value, and pass the entry id as resetKey so a client-side navigation to another entry measures the new page. useActiveHeading(ids, { offset, resetKey }) is the same logic for your own markup. Neither table is sticky on its own; add the class you need.

Reactions and the helpful vote

Reactions renders the five reaction types with their emoji and counts. It needs no provider, takes the same config as KookeeProvider, and remembers in localStorage what this visitor pressed:

import { Reactions } from '@kookee/react/entry/client';

<Reactions entryId={post.id} reactions={post.reactions} config={{ apiKey: 'your-api-key' }} />;
  • The server render shows the counts; the visitor's own reactions are pressed after hydration.
  • A click updates the count at once, adopts the counts the server returns, and rolls back if the request fails. One request runs per entry at a time; meanwhile every button has aria-disabled.
  • Every Reactions for the same entry on the page stays in step.
  • options replaces the buttons: { type, label, icon }[]. A custom type must match [a-z0-9_]{1,50}, or the request fails and the root gets data-error.
  • storageKey (default kookee:reactions) holds Record<entryId, string[]>; give each reaction widget of a site its own.
  • onReact runs after each successful request, for example to refresh cached data.
  • config={null}, on a site where Kookee is not configured, renders nothing.

HelpfulVote from @kookee/react/help/client asks "Was this article helpful?" with yes and no, stored as the yes and no reactions of the article. A visitor has one vote: choosing the other removes the first. Each button shows a thumb (icons replaces them, icons={{}} drops them), and the root carries data-vote (yes, no, or none).

<HelpfulVote entryId={article.id} reactions={article.reactions} config={config} showCounts />

Both are built on useReactions({ entryId, reactions, config, storageKey?, exclusive? }), which returns { counts, mine, toggle, isPending, error }. The root export's useReact is the bare request, without this state.

Reactions are anonymous by design: a determined visitor can inflate them, within the API's per-IP rate limit.

Help center pages

<HelpBreadcrumbs
  homeHref="/help"
  category={article.category}
  categoryHref="/help/{categorySlug}"
  article={article}
  before={[{ label: 'Home', href: '/' }]}
/>
<HelpCategoryHeader category={category} />
<HelpArticleList articles={articles} href="/help/{categorySlug}/{slug}" showViews />
<HelpCategoryNav categories={categories} activeCategorySlug={category.slug} href="/help/{categorySlug}" showCounts />

HelpBreadcrumbs ends with the current page, unlinked with aria-current="page". HelpCategoryHeader prints labels.categoryEyebrow ("Category"; an empty string hides it), the icon and name as the heading, the description, and the article count unless showCount={false}.

HelpArticleList passes labels, locale, showCategory, showViews, and highlight to its default cards. showViews ends each row with the view count; showCategory prints the category after the title, as text. numbered renders an <ol>, drawn as a two-column grid of ranked cards (01, 02, …) without snippets, for a "popular articles" list:

<HelpArticleList articles={popular} href="/help/{categorySlug}/{slug}" numbered />

HelpArticleNav lists an article's siblings beside it, the one being read marked aria-current="page":

<HelpArticleNav articles={siblings} activeSlug={article.slug} href="/help/{categorySlug}/{slug}" title={category.name} />

HelpArticleViews renders an article's view count, labels.views, for your own rows.

Article headers

Each detail page's header puts the title first and a mono meta row under it:

  • BlogPost / BlogPostHeader: the first tag (linked with tagHref), @author, and the date, then a 2:1 cover. coverPlaceholder fills the cover's box for a post without one.
  • ChangelogArticle / ChangelogArticleHeader: the version, the type badge, @author with showAuthor, and the date, then the cover. ChangelogArticle's showLink adds the entry's link field after the body, "Learn more ↗" (labels.learnMore), in a new tab; only an http: or https: URL is linked.
  • HelpArticle / HelpArticleHeader: showCategory prints the category above the title, and the meta row holds the last-updated date and, with showViews, the view count.
<ChangelogArticle entry={entry} showAuthor showLink labels={{ versionPrefix: 'v' }} />
<HelpArticle article={article} showCategory showViews />

headerClassNames passes classNames to the header, so its slots take classes without composing the header yourself. BlogPostAuthor and ChangelogAuthor are the author parts on their own, with the avatar.

Roadmap

A board of your feedback columns and posts. Link the cards to a page of yours or to your hosted portal:

import { Roadmap } from '@kookee/react/roadmap';

const [columns, posts] = await Promise.all([
  kookee.feedback.getColumns(),
  kookee.feedback.list({ sort: 'top', limit: 100, columnType: 'open' }),
]);

<Roadmap columns={columns} posts={posts.data} href="https://feedback.example.com/roadmap?post={id}" />;

Hidden columns, and posts outside the visible columns, are left out; groupRoadmapPosts does that grouping for your own markup. columnType: 'open' fetches only the posts in open columns: closed columns still render, empty, unless you also pass columns.filter((column) => column.type === 'open'). The root is the horizontal scroll container, so ref and onScroll reach it; the stylesheet fades an edge that has more columns past it, with a scroll-driven animation that browsers without them skip. Each column carries data-column-id, data-column-type (open or closed), data-column-name, and its colour as --kookee-column-color, which tints it; a closed column without a colour of its own takes --kookee-success.

Each card shows its title, text, a ▲ 42 vote pill, and its category. Voting is yours: the public API has no vote endpoint. isVoted marks the cards the visitor voted for (data-voted, a solid pill), and renderVotes replaces the pill with your own vote button, which sits above the card's link:

<Roadmap
  columns={columns}
  posts={posts.data}
  href="/roadmap?post={id}"
  isVoted={(post) => myVotes.has(post.id)}
  renderVotes={(post) => <VoteButton post={post} />}
/>

One request returns at most 100 posts. For a larger board, fetch each column on its own (feedback.list({ columnId, limit })) and pass the full counts as columnTotals={{ [columnId]: total }}.

RoadmapCategoryFilter links each category, like ChangelogTypeFilter. To show a search, pass the query as highlight: the default cards mark its matches in the title and the text, and cut the text to start a few whole words before the first match. The query is trimmed and lowercased, and one shorter than two characters leaves the cards as they are. The blog, changelog, help, and feedback lists take highlight the same way.

<RoadmapCategoryFilter
  activeCategory={category}
  href={(category) => `/roadmap?${new URLSearchParams({ q, category })}`}
  allHref={`/roadmap?${new URLSearchParams({ q })}`}
/>
<Roadmap columns={columns} posts={posts.data} highlight={q} />

Cards from renderItem mark their own: pass highlight to the title and excerpt parts, or use Highlight and snippetAround from @kookee/react/entry in any markup. Highlight wraps each match in <mark class="kookee-highlight kookee-content">, coloured with --kookee-highlight, and passes its other props to every mark.

Feedback

@kookee/react/feedback renders the feedback board read-only, from the SDK's feedback.list(), getById(), getComments(), and getTopContributors(). Posting, voting, and commenting are not in the public API, so they stay yours: renderVotes puts your vote button where the ▲ 42 box is.

import { FeedbackPostList, FeedbackSortFilter, FeedbackTopContributors } from '@kookee/react/feedback';
import { RoadmapCategoryFilter } from '@kookee/react/roadmap';

<FeedbackSortFilter activeSort={sort} href="/feedback?sort={sort}" />
<RoadmapCategoryFilter activeCategory={category} href="/feedback?category={category}" allHref="/feedback" />
<FeedbackPostList
  posts={posts.data}
  href="/feedback/{id}"
  highlight={q}
  isVoted={(post) => myVotes.has(post.id)}
  renderVotes={(post) => <VoteButton post={post} />}
/>
<FeedbackTopContributors contributors={contributors} />

Each row shows the vote count, the title, the text, a status tag (shipped for a post in a closed column, under review otherwise; statusLabel(post) renames it, or returns undefined to keep it), the category, the author and date, and the comment count. A row carries data-category, data-status (shipped or open), data-has-link, and, with isVoted, data-voted. Its title is an h2; headingLevel changes it.

A post's page is FeedbackPostHeader (the vote count beside the title, then the category, the column as a status pill, the author, and the date), FeedbackPostContent (the post's HTML, else its text, else labels.noDescription), and FeedbackCommentList (a "Comments (N)" heading, then each comment with its author, an Official or Team badge, and the date). FeedbackPostMeta is the column beside a post in a dialog: the vote count, the author, and the creation date; there, give the header renderVotes={() => null} so the count shows once. The header also takes statusLabel. Comment attachments are not rendered: the API gives their storage path, not a URL.

Status page

@kookee/react/status renders the status page Kookee hosts at https://{slug}.kookee.dev/status, from the SDK's status.get() and status.getIncident(id). The hosted page is built from these components too.

import { StatusHeadline, StatusIncidentArticle, StatusOverview } from '@kookee/react/status';

// app/status/page.tsx
const status = await kookee.status.get();

<StatusHeadline status={status} />
<p>Incidents, maintenance and the uptime of the last 90 days.</p>
<StatusOverview status={status} incidentHref="/status/incidents/{id}" renderLink={renderLink} />

// app/status/incidents/[id]/page.tsx
const incident = await kookee.status.getIncident(id);

<StatusIncidentArticle incident={incident} />

StatusHeadline is the overall state as a heading ("All systems operational", "Minor service disruption", "Major outage", "Maintenance in progress") after a dot in its colour. It is a component of its own so your page can put text between it and the sections.

StatusOverview is the rest, in the hosted page's order:

  • active incidents;
  • maintenance in progress;
  • upcoming maintenance;
  • the components;
  • the incidents of the last 90 days.

A section with nothing in it is left out, except the past incidents, which say there were none. An incident card shows its status, its impact or maintenance window, the components it affects, and its latest update.

Each component shows its status and, while its uptime is shown, a bar per day:

  • the last 90 days, or the last 30 in a container narrower than 30rem;
  • each day's date and status on hover;
  • the 90-day uptime, floored to two decimals.

StatusIncidentArticle is one incident's page: its status and kind, the title, its facts, and every update, newest first, on a timeline.

StatusBadge is the state as a small dot and label for a header or a footer, a link with href.

Freshness. The API caches /v1/status for 30 seconds. Status reads count against neither the monthly quota nor the other public reads: they share a per-project bucket of ten times the plan's rate limit.

  • On a server: cache the read for about a minute, and revalidate it on the incident.created, incident.updated, monitor.down, and monitor.up webhooks.
  • In the browser: useStatus({ refreshSeconds: 60 }) keeps an open page current.

A status set by hand, an edited incident, and a maintenance window starting or ending send no webhook, so they show on the next read.

Times.

  • They show with their zone, in UTC by default: Oct 12, 2026, 6:15 AM UTC.
  • A maintenance window on one day ends with the time alone: Oct 12, 2026, 6:12 AM – 7:30 AM UTC.
  • timeZone picks the zone. In the browser, pass the visitor's: Intl.DateTimeFormat().resolvedOptions().timeZone. An unknown zone falls back to UTC.
  • formatDate replaces every date and time, and is called once per end of a window.

The portal's Status page switch governs only the hosted page: an API key reads /v1/status whether it is on or not.

Theming

Every root the package renders — components, parts, and highlight marks — carries the class kookee-content. Map your design tokens onto it once, and every component takes your palette:

.kookee-content {
  --kookee-bg: var(--card);
  --kookee-bg-secondary: var(--muted);
  --kookee-text: var(--foreground);
  --kookee-text-secondary: var(--muted-foreground);
  --kookee-text-dim: var(--muted-foreground);
  --kookee-border: var(--border);
  --kookee-primary: var(--primary);
  --kookee-primary-text: var(--primary);
  --kookee-primary-contrast: var(--primary-foreground);
  --kookee-primary-soft: color-mix(in srgb, var(--primary) 12%, transparent);
  --kookee-focus-ring: 0 0 0 3px color-mix(in srgb, var(--primary) 22%, transparent);
  --kookee-font-mono: var(--font-mono);
  --kookee-radius: 16px;
}
  • Set --kookee-primary-soft and --kookee-focus-ring with --kookee-primary: the stylesheet derives them from the primary colour at :root, so they don't follow one set here.
  • --kookee-primary-text is the accent-coloured text of active and pressed items, a colour of its own so it stays readable on both themes.
  • The chat widget reads the same variable names, but its roots don't carry kookee-content, so it keeps its own theme.
  • Without a .kookee-content rule, variables set on an ancestor of a component, or on its root, work as before. With one, every nested root re-applies it, so give one section its own palette on the roots inside it: .promo .kookee-content { --kookee-primary: … }.
  • --kookee-body-size sets the size of an entry body with kookee-typography (16 px, 14.5 px in changelog entries, help articles, and feedback).
  • Announcement types take --kookee-info (info), --kookee-warn (warning, maintenance), --kookee-danger (critical), --kookee-primary-text (promotion), and --kookee-success (newFeature). Recolour one type with --kookee-announcement-info, -warning, -critical, -promotion, -maintenance, or -new-feature.

Callouts and button links in an entry body take the same tokens. A callout (<aside data-callout>) gets a thin outline in its variant's colour, and every variant but note a soft tint of it too: note --kookee-info, tip --kookee-success, important --kookee-important, warning --kookee-warn, caution --kookee-danger. A button link (<a data-button href>) is filled with --kookee-primary, its text in --kookee-primary-contrast. Both apply in every EntryContent, with kookee-typography or without it, since your own typography has no rule for them. A link with no href (an entry link from a client without getPath; see the @kookee/sdk README) reads as text.

With the stylesheet in a cascade layer and Tailwind's prose around the body, prose restyles the button's text and the margins inside a callout. Add this unlayered guard once:

.kookee-entry-content a[data-button][href] {
  color: var(--kookee-primary-contrast);
  text-decoration: none;
}

.kookee-entry-content aside[data-callout] > :first-child {
  margin-block-start: 0;
}

.kookee-entry-content aside[data-callout] > :last-child {
  margin-block-end: 0;
}

Every variable is listed in the reference.

Dark mode

Without your own palette, add kookee-dark to any ancestor for dark, or kookee-auto to follow the operating system:

<html className={theme === 'dark' ? 'dark kookee-dark' : undefined}>

kookee-dark and kookee-auto switch only the package's own defaults. With your own palette, map your tokens on .kookee-content as above: they already switch with your dark mode, so leave kookee-dark out.

Customizing

The look is the portal's by default. Four levels change it, from a variable to full control. Each one leaves the others working.

1. CSS variables: the tokens above, plus --kookee-card-min (the minimum card width of the category grid), --kookee-gap, and --kookee-cover-ratio. A neutral site tones the branded details down through them: --kookee-font-mono for the mono labels, --kookee-highlight for search marks.

2. className and classNames add classes to the root and to any inner element (slot). Lists take itemClassNames for every item, and article pages headerClassNames for their header. More slots reach inside the parts: titleLink, the title's link, on every card and changelog item; coverImage, the cover's image, on blog cards, changelog items, and the blog and changelog headers; coverPlaceholder on blog cards and the blog header; and authorName on the changelog header:

<ChangelogList
  entries={entries}
  className="my-12"
  classNames={{ list: 'divide-y' }}
  itemClassNames={{ title: 'text-xl', titleLink: 'underline', badge: 'uppercase' }}
/>

Or target the stable class names from your own CSS. A one-class rule wins over the stylesheet in every state: its state rules (:hover, [data-active], [data-first]) only set --kookee-* variables that its one-class base rules read.

.kookee-changelog-item-title {
  font-size: 1.25rem;
}

3. Parts, children, renderItem, renderLink, renderImage change the structure. Every default layout is built from exported parts, so a custom row reuses them:

import { ChangelogList, ChangelogTypeBadge, ChangelogTitle, ChangelogDate } from '@kookee/react/changelog';

<ChangelogList
  entries={entries}
  renderItem={(entry) => (
    <div className="flex items-baseline gap-3">
      <ChangelogTypeBadge entry={entry} />
      <ChangelogTitle entry={entry} href="/changelog/{slug}" headingLevel={3} />
      <ChangelogDate entry={entry} />
    </div>
  )}
/>;

The list still wraps each item in its own <li>. Title parts link only the title unless you pass stretched, which makes the nearest positioned ancestor clickable; the default cards do. The roadmap's title and excerpt parts also take children, which replace the text.

4. Headless hooks keep the behaviour of the interactive components and drop the markup: useReactions, useActiveHeading, useWhatsNew, and useHelpSearchBox.

Every component also forwards ref and native attributes (id, aria-*, data-testid) to its root element.

Images

Covers load lazily, except the first item of a list and the cover of a detail page, which load eagerly with a high fetch priority. Pass priority to a card, header, or cover part to change that. renderImage swaps in next/image or your image CDN:

import Image from 'next/image';

<BlogPostList
  posts={posts}
  renderImage={({ src, alt, className, priority }) => (
    <Image src={src} alt={alt} className={className} priority={priority} fill sizes="(min-width: 768px) 33vw, 100vw" />
  )}
/>;

The cover box is positioned with a fixed aspect ratio, so fill fits it. Add the Kookee media host to images.remotePatterns in next.config.

Blog cards keep one shape: a post without a cover shows a quiet tinted placeholder in the cover's box, with the post's first tag ("Engineering") or nothing for a post without tags, marked data-placeholder="true" and hidden from assistive technology. coverPlaceholder on BlogPostList or BlogPostCard replaces it, with content or a function of the post; null leaves no box. BlogPost, BlogPostHeader, and BlogPostCover (placeholder) show one only when you pass it; true gives an empty box.

<BlogPostList posts={posts} coverPlaceholder={(post) => post.tags[0]?.name ?? 'Kookee'} />

Dates and labels

Dates are absolute (Sep 15, 2026), in UTC, so the server render and hydration agree. locale changes the format; formatDate replaces it, for example with relative dates your app computes after hydration. The status page shows times of day too, with their zone, in UTC unless you pass timeZone (see Status page).

Labels come in one object per area — BlogLabels, ChangelogLabels, HelpLabels, EntryLabels, RoadmapLabels, FeedbackLabels, StatusLabels, AnnouncementLabels — and every component of the area takes it as labels, using its own keys. A translated site builds one object per area and passes it everywhere. Counted labels take plural forms, resolved for locale:

<HelpCategoryGrid
  categories={categories}
  locale="ru"
  labels={{ articleCount: { one: '{count} статья', few: '{count} статьи', many: '{count} статей', other: '{count} статьи' } }}
/>

What's new

WhatsNew shows how many changelog entries were published since the visitor last opened it, and lists them in a popover. The last-seen date is kept in localStorage, so the count appears after hydration, never in the server render.

import { WhatsNew } from '@kookee/react/changelog/client';

<WhatsNew entries={recentEntries} href="/changelog/{slug}" allHref="/changelog" />;

It takes open, defaultOpen, and onOpenChange to control the popover, and storageKey when one origin shows more than one project. To build your own badge:

const { unseenCount, isUnseen, markSeen } = useWhatsNew({ entries: recentEntries });

unseenCount is null until hydrated and when storage is unavailable.

Announcements

An announcement shows as a pill, which opens the list, which opens one announcement in full. The package renders the three pieces; the container they open in, a dialog, a sheet, or a page, is your app's, so it moves focus and animates the way the rest of your app does.

  • AnnouncementPill shows one announcement's type and title, and a dismiss button. It renders nothing without an announcement, and is not positioned: place it under your header.
  • AnnouncementList lists them with their type, date, and a dismiss button.
  • AnnouncementArticle shows one in full. List responses carry no body, so it takes the detail useAnnouncement(id) or client.announcements.getById returns.
  • useDismissedAnnouncements keeps what the visitor dismissed in localStorage, by translation group, so a dismissal holds in every language. Its visible list fills in after hydration, never in the server render; storageKey changes the key (kookee:announcements:dismissed).
'use client';
import { useState } from 'react';
import { useAnnouncement, useAnnouncements } from '@kookee/react';
import { AnnouncementArticle, AnnouncementList, AnnouncementPill } from '@kookee/react/announcements';
import { useDismissedAnnouncements } from '@kookee/react/announcements/client';

export function Announcements() {
  const { data: announcements } = useAnnouncements({ limit: 20 });
  const { visible, dismiss } = useDismissedAnnouncements({ announcements });
  const [open, setOpen] = useState(false);
  const [selectedId, setSelectedId] = useState<string>();
  const { data: selected } = useAnnouncement(selectedId);

  const close = () => {
    setOpen(false);
    setSelectedId(undefined);
  };

  return (
    <>
      <AnnouncementPill announcement={visible[0]} onOpen={() => setOpen(true)} onDismiss={dismiss} />
      <YourDialog open={open} onClose={close}>
        {selectedId && selected?.id === selectedId ? (
          <AnnouncementArticle announcement={selected} />
        ) : (
          <AnnouncementList
            announcements={visible}
            onSelect={(announcement) => setSelectedId(announcement.id)}
            onDismiss={dismiss}
          />
        )}
      </YourDialog>
    </>
  );
}

useAnnouncement keeps its previous result while a new id loads and after the id goes back to undefined, hence the id check. onOpen, onSelect, and onDismiss are functions, so the component that passes them is a client component.

The pill shows the newest announcement the visitor has not dismissed, however old. To show only recent ones, filter by publishedAt before passing the list in. A fixed pill under a 64 px header:

.announcement-slot {
  position: fixed;
  inset-inline: 0;
  inset-block-start: 4.5rem;
  display: flex;
  justify-content: center;
  pointer-events: none;
}

.announcement-slot .kookee-announcement-pill {
  pointer-events: auto;
}

Help search

HelpSearchBox needs no provider: it takes the same config as KookeeProvider. It searches from two characters, 300 ms after typing stops, and announces loading, the result count, and errors to screen readers.

import { HelpSearchBox } from '@kookee/react/help/client';

<HelpSearchBox
  config={{ apiKey: 'your-api-key' }}
  href="/help/{categorySlug}/{slug}"
  navigate={(href) => router.navigate({ href })}
  onSubmit={(query) => router.navigate({ href: `/help/search?q=${encodeURIComponent(query)}` })}
/>;

It is an ARIA combobox: the results are options of a listbox, and the focus stays in the input.

  • Up and Down move the active result, Home and End jump to the first and last, and Up from the first returns to the query. The first result becomes active once the results for the current query arrive, and never while a newer query loads or after an error.
  • Enter opens the active result through navigate(href, result), which defaults to location.assign(href); pass your router's navigation in a client-side app. With no active result, Enter calls onSubmit(query), for a full results page. Keys an input method uses while it composes (Japanese, Chinese, Korean) are left to it.
  • config={null}, on a site where Kookee is not configured, renders nothing.
  • A click opens a result; a ⌘/Ctrl-click, a middle click, or ⌘/Ctrl-Enter opens it in a new tab. Results are not links, so they have no "Copy link" menu.
  • Escape clears the query, and a second Escape leaves the box.

Each result shows its title, its category after it (showCategory={false} hides it), and the matching snippet, with the query's matches marked. A result whose href resolves to null is left out. footer renders after the results, for example a "Search all articles" button, and onResultClick runs when a result is opened, for analytics. labels.searchPrompt shows until the query is two characters long, and labels.searchNoResultsHint under "No articles found".

searchLocale is the content locale sent to the search; locale only formats the count. query, defaultQuery, and onQueryChange sync the query with the URL. For your own search UI, useHelpSearchBox returns { query, setQuery, results, resultsQuery, status, error }, where resultsQuery is the query the results answer. It is a different hook from the root's useHelpSearch, which needs KookeeProvider.

Reference pages

The package's playground (pnpm playground in its repository) renders every portal page from the components alone — blog list and post, changelog list and entry, help index, category, article, search, and the ⌘K dialog, roadmap, feedback list, post, and dialog, the status page and an incident, and the announcement pill with its list and article — with the default tokens and two app palettes, in light and dark. Start from them when you build yours.

Reference

Slots. classNames keys, and the classes they add to. root is always the component's own class, next to kookee-content.

| Component | Root class | Slots | | --- | --- | --- | | EntryContent | kookee-entry-content | — | | EmptyState | kookee-empty-state | icon, title, description, action | | TableOfContents, TrackedTableOfContents | kookee-toc | title, list, item, link | | Pagination | kookee-pagination | list, item, link, previous, next, ellipsis | | Reactions | kookee-reactions | button, icon, label, count | | BlogPostList | kookee-blog-list | list, item, empty | | BlogPostCard | kookee-blog-card | cover, body, meta, tag, date, title, excerpt, author, titleLink, coverImage, coverPlaceholder | | BlogPost | kookee-blog-post | header, content | | BlogPostHeader | kookee-blog-post-header | title, meta, tag, author, date, cover, coverImage, coverPlaceholder | | BlogTagFilter | kookee-blog-tag-filter | list, item, link, name, count, toggle, toggleButton | | BlogCategoryNav | kookee-blog-category-nav | list, item, link, icon, name, count, description | | ChangelogList | kookee-changelog-list | list, item, empty | | ChangelogItem | kookee-changelog-item | meta, date, version, body, heading, title, badge, summary, cover, titleLink, coverImage | | ChangelogArticle | kookee-changelog-article | header, content, link | | ChangelogArticleHeader | kookee-changelog-article-header | title, meta, version, badge, author, date, cover, coverImage, authorName | | ChangelogTypeFilter | kookee-changelog-type-filter | list, item, link | | WhatsNew | kookee-whats-new | trigger, label, badge, panel, title, list, item, date, link, new, empty, all | | HelpCategoryGrid | kookee-help-category-grid | list, item, empty | | HelpCategoryCard | kookee-help-category-card | icon, title, description, count, titleLink | | HelpArticleList | kookee-help-article-list | list, item, empty | | HelpArticleCard | kookee-help-article-card | body, heading, title, category, snippet, views, titleLink | | HelpArticle | kookee-help-article | header, content | | HelpArticleHeader | kookee-help-article-header | category, title, meta, updated, views | | HelpArticleNav | kookee-help-article-nav | title, list, item, link | | HelpCategoryHeader | kookee-help-category-header | eyebrow, title, icon, name, description, count | | HelpCategoryNav | kookee-help-category-nav | list, item, link, icon, name, count | | HelpBreadcrumbs | kookee-help-breadcrumbs | list, item, link, current, separator | | HelpSearchBox | kookee-help-search | label, input, status, prompt, hint, list, option, title, category, snippet, footer | | HelpfulVote | kookee-helpful-vote | question, options, button, icon, label, count | | Roadmap | kookee-roadmap | empty | | RoadmapColumn | kookee-roadmap-column | header, dot, title, count, list, item, empty | | RoadmapPostCard | kookee-roadmap-card | title, excerpt, meta, votes, voteControl, category, titleLink | | RoadmapCategoryFilter | kookee-roadmap-category-filter | list, item, link | | FeedbackPostList | kookee-feedback-list | list, item, empty | | FeedbackPostRow | kookee-feedback-row | votes, votesCount, voteControl, body, title, excerpt, meta, status, category, byline, date, comments, titleLink | | FeedbackSortFilter | kookee-feedback-sort-filter | list, item, link | | FeedbackPostHeader | kookee-feedback-post-header | votes, votesCount, voteControl, body, title, meta, category, status, author, avatar, authorName, date | | FeedbackPostContent | kookee-feedback-post-content | body, text, empty | | FeedbackPostMeta | kookee-feedback-post-meta | upvotes, votes, votesCount, voteControl, upvotesLabel, details, detail, term, value, avatar, name, date | | FeedbackCommentList | kookee-feedback-comments | title, empty, list, item, comment, header, avatar, author, badge, date, body | | FeedbackTopContributors | kookee-feedback-top-contributors | title, empty, list, item, rank, avatar, name, votes, votesCount | | FeedbackPostSkeleton | kookee-feedback-post-skeleton | header, body | | every list skeleton | its list's root class | list, item, card, block | | StatusHeadline | kookee-status-headline | dot, label | | StatusOverview | kookee-status-overview | section, sectionTitle, incidents, components, pastIncidents, empty | | StatusIncidentArticle | kookee-status-incident-article | header, meta, status, kind, title, facts, updates, updatesTitle, timeline, item, dot, update | | StatusBadge | kookee-status-badge | link, dot, label | | AnnouncementPill | kookee-announcement-pill | open, icon, title, dismiss | | AnnouncementList | kookee-announcement-list | list, item, empty | | AnnouncementList rows (itemClassNames) | kookee-announcement-item | icon, body, title, titleButton, excerpt, meta, badge, date, dismiss | | AnnouncementArticle | kookee-announcement-article | header, icon, title, meta, badge, date, content |

A slot's class is the root class plus -<slot>, a two-word slot in kebab case (kookee-blog-card-title, kookee-blog-tag-filter-toggle-button); titleLink, coverImage, coverPlaceholder, and authorName on the headers and cards add no class of their own. Parts carry their own class as well (kookee-blog-post-title, kookee-changelog-type-badge, kookee-roadmap-post-votes). Title parts have a link slot, BlogPostCover image and placeholder, RoadmapPostVotes count, and the author parts avatar and name. Highlight's marks have kookee-highlight, and the · in meta rows kookee-separator. Inside the status components, these pieces carry their own classes rather than slots:

  • kookee-status-incident-card, kookee-status-component, kookee-status-uptime (its -day bars and -footer), and kookee-status-past-incident;
  • kookee-status-facts, kookee-status-update, and kookee-status-pill (a status on a tint, with its -dot).

Labels.

| Type | Keys (English defaults) | | --- | --- | | EntryLabels | tocTitle (On this page; empty hides it), tocNavigation (Table of contents), paginationNavigation (Pagination), paginationPrevious (Previous), paginationNext (Next), paginationPage (Page {page}), reactionsGroup (Reactions), reactionFire (Fire), reactionHeart (Love), reactionRocket (Rocket), reactionEyes (Eyes), reactionMindblown (Mind blown) | | BlogLabels | empty (No posts yet.), tagFilter (Filter posts by tag), allTags (All posts), showMore (Show {count} more), showLess (Show less), coverPlaceholder (cover), categoryNavigation (Categories) | | ChangelogLabels | empty (No updates yet.), versionPrefix (empty), feature (Feature), fix (Fix), improvement (Improvement), breaking (Breaking), security (Security), deprecated (Deprecated), other (Update), typeFilter (Filter updates by type), allTypes (All), learnMore (Learn more), whatsNewTitle (What's new), whatsNewUnseen (plural: {count} new updates), whatsNewNew (New), whatsNewViewAll (View all updates), whatsNewEmpty (No updates yet.) | | HelpLabels | noCategories (No categories yet.), noArticles (No articles yet.), articleCount (plural: {count} articles), lastUpdated (Updated {date}), breadcrumbs (Breadcrumb), breadcrumbsHelpCenter (Help Center), categoryNavigation (Categories), categoryEyebrow (Category), articleNavigation (Articles in this category), helpfulQuestion (Was this article helpful?), helpfulYes (Yes), helpfulNo (No), searchLabel (Search help articles), searchPlaceholder (Search for answers…), searchPrompt (Type to search the articles.), searchLoading (Searching…), searchError (Search failed. Please try again.), searchNoResults (No articles found.), searchNoResultsHint (Try other words, or browse the categories.), searchResultCount (plural: {count} articles found), views (plural: {count} views) | | RoadmapLabels | empty (Nothing on the roadmap yet.), emptyColumn (Nothing here yet.), feature (Feature), improvement (Improvement), bug (Bug), other (Other), votes (plural: {count} votes), comments (plural: {count} comments), categoryFilter (Filter posts by category), allCategories (All) | | StatusLabels | indicatorOperational (All systems operational), indicatorMinor (Minor service disruption), indicatorMajor (Major outage), indicatorMaintenance (Maintenance in progress), componentOperational (Operational), componentDegradedPerformance (Degraded performance), componentPartialOutage (Partial outage), componentMajorOutage (Major outage), componentUnderMaintenance (Under maintenance), incidentInvestigating (Investigating), incidentIdentified (Identified), incidentMonitoring (Monitoring), incidentResolved (Resolved), incidentScheduled (Scheduled), incidentInProgress (In progress), incidentCompleted (Completed), impactNone (No impact), impactMinor (Minor), impactMajor (Major), impactCritical (Critical), kindIncident (Incident), kindMaintenance (Maintenance), sectionActiveIncidents (Active incidents), sectionMaintenanceInProgress (Maintenance in progress), sectionUpcomingMaintenance (Upcoming maintenance), sectionComponents (Components), sectionPastIncidents (Past incidents), sectionUpdates (Updates), factImpact (Impact), factWindow (Scheduled), factComponents (Affected components), uptimeDaysAgo (plural: {count} days ago), uptimeToday (Today), uptimePercent ({percent}% uptime), uptimeNoData (No data), noPastIncidents (No incidents in the last 90 days.), badgePrefix (Service status) | | AnnouncementLabels | info (Info), warning (Warning), critical (Critical), promotion (Promotion), maintenance (Maintenance), newFeature (New feature), empty (No announcements), dismiss (Dismiss) | | FeedbackLabels | empty (No feedback yet.), feature (Feature), improvement (Improvement), bug (Bug), other (Other), top (Top), newest (Newest), trending (Trending), `so