@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
Maintainers
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 —
initialDatahands 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 undefinedGeneric 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 updateuseStatusIncident(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=2stays 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
Reactionsfor the same entry on the page stays in step. optionsreplaces the buttons:{ type, label, icon }[]. A customtypemust match[a-z0-9_]{1,50}, or the request fails and the root getsdata-error.storageKey(defaultkookee:reactions) holdsRecord<entryId, string[]>; give each reaction widget of a site its own.onReactruns 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 withtagHref),@author, and the date, then a 2:1 cover.coverPlaceholderfills the cover's box for a post without one.ChangelogArticle/ChangelogArticleHeader: the version, the type badge,@authorwithshowAuthor, and the date, then the cover.ChangelogArticle'sshowLinkadds the entry'slinkfield after the body, "Learn more ↗" (labels.learnMore), in a new tab; only anhttp:orhttps:URL is linked.HelpArticle/HelpArticleHeader:showCategoryprints the category above the title, and the meta row holds the last-updated date and, withshowViews, 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, andmonitor.upwebhooks. - 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. timeZonepicks the zone. In the browser, pass the visitor's:Intl.DateTimeFormat().resolvedOptions().timeZone. An unknown zone falls back to UTC.formatDatereplaces 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-softand--kookee-focus-ringwith--kookee-primary: the stylesheet derives them from the primary colour at:root, so they don't follow one set here. --kookee-primary-textis 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-contentrule, 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-sizesets the size of an entry body withkookee-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.
AnnouncementPillshows 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.AnnouncementListlists them with their type, date, and a dismiss button.AnnouncementArticleshows one in full. List responses carry no body, so it takes the detailuseAnnouncement(id)orclient.announcements.getByIdreturns.useDismissedAnnouncementskeeps what the visitor dismissed inlocalStorage, by translation group, so a dismissal holds in every language. Itsvisiblelist fills in after hydration, never in the server render;storageKeychanges 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 tolocation.assign(href); pass your router's navigation in a client-side app. With no active result, Enter callsonSubmit(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-daybars and-footer), andkookee-status-past-incident;kookee-status-facts,kookee-status-update, andkookee-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
