The components come with one optional stylesheet and four ways to change them, from a colour to full control over the markup. Each level leaves the others working, so you can restyle one card and keep the defaults everywhere else.
The stylesheets
import '@kookee/react/content.css'; // the components
import '@kookee/sdk/styles/content.css'; // code blocks and file chips inside entry bodies
Without @kookee/react/content.css the components render unstyled, with every class name and data-* attribute still in place. Apart from declaring its variables on :root, the stylesheet targets only kookee- classes, never bare elements, and contains no reset, so it cannot restyle the rest of your site.
@kookee/react/content.css styles the text of the entry bodies the components render, which carry the kookee-typography class: headings, links, lists, quotes, code, images and tables, with --kookee-body-size setting the text size. A standalone EntryContent is left to your own typography, such as Tailwind's prose; give it className="kookee-typography" for the same styles. For an entry body you render without the components, @kookee/sdk/styles/typography.css gives baseline styles.
Tailwind v4
Import both stylesheets 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 rules for h1 to h6, ol and img then override the components' own.
Give the lists a width
BlogPostList, ChangelogList, HelpCategoryGrid and HelpArticleList are size containers (container-type: inline-size), so their layout follows their own width, not the window's. A size container takes no width from its content, so give it a definite one: a block, a stretched flex child, or a 1fr grid track. As a shrink-to-fit child, such as a flex item without a width, w-fit or an auto grid track, the list collapses to zero width.
Level 1: CSS variables
Set the theme with variables:
:root {
--kookee-primary: #0f766e;
--kookee-primary-text: #0f766e; /* active and pressed text */
--kookee-radius: 8px;
--kookee-card-min: 20rem; /* minimum card width in the grids */
}
Load these after the stylesheet, or keep them unlayered when the stylesheet sits in a layer, because the stylesheet declares its own defaults on :root too.
Variable | What it sets |
|---|---|
| accent colour of links, active items and buttons |
| the light accent tint behind hovered, pressed and active items, and behind search matches |
| accent-coloured text; lighter in dark mode, so it stays readable |
| card and page-section backgrounds |
| main and muted text |
| borders, and error text |
| corner radius of cards and small elements |
| shadows, the keyboard focus ring and hover transitions |
| grid card width, spacing between items, and the aspect ratio of covers |
| z-index of the what's-new popover |
| one badge colour per changelog release type |
| one colour per announcement type ( |
| set by each roadmap column from the colour you gave it |
The React chat widget reads the same variable names. When it is on the same page, set your values on the components' roots instead of :root, so the chat keeps its own look. On a root, also set the two variables derived from the accent, because the defaults are computed from the :root value:
.kookee-blog-list,
.kookee-toc {
--kookee-primary: var(--brand);
--kookee-primary-text: var(--brand);
--kookee-primary-soft: color-mix(in srgb, var(--brand) 12%, transparent);
--kookee-focus-ring: 0 0 0 3px color-mix(in srgb, var(--brand) 22%, transparent);
}
Dark mode
Without a palette of your own, add the kookee-dark class to any ancestor for dark, or kookee-auto to follow the visitor's operating system:
<html className={theme === 'dark' ? 'dark kookee-dark' : undefined}>
With your own palette, map the variables to your design tokens, which already switch with your dark mode, and leave kookee-dark out:
:root {
--kookee-bg: var(--color-background);
--kookee-text: var(--color-foreground);
--kookee-text-secondary: var(--color-muted-foreground);
--kookee-border: var(--color-border);
--kookee-primary: var(--color-primary);
--kookee-primary-text: var(--color-primary);
}
Do one or the other. Mixed, your :root mapping wins over the stylesheet's .kookee-dark block once the stylesheet sits in a layer, so kookee-dark changes only the variables you did not map.
Level 2: classes
Every component takes className for its root and classNames for its inner elements, called slots. Lists also take itemClassNames, applied to every item, and BlogPost, ChangelogArticle and HelpArticle take headerClassNames for the inner elements of their header. A few more slots reach inside the parts: titleLink, the link inside a card's title, on every card; coverImage, the image inside a cover, on blog cards, changelog items and the blog and changelog headers; coverPlaceholder on blog cards; and authorAvatar and authorName on the changelog header:
<ChangelogList
entries={entries}
className="my-12"
classNames={{ list: 'divide-y' }}
itemClassNames={{ title: 'text-xl', titleLink: 'no-underline', badge: 'uppercase' }}
/>
Or write CSS against the stable class names. A slot's class is the root's class plus the slot name, so the title of a blog card is kookee-blog-card-title:
.kookee-blog-card-title {
font-size: 1.25rem;
}
Each default rule is a single class, so one class of yours, loaded after the stylesheet, is enough to override it, in every state.
Level 3: structure
When the layout itself has to change:
Parts. Every default layout is built from exported parts, such as
BlogPostTitle,BlogPostCoverandChangelogTypeBadge. Compose them in a different order or leave some out.renderItemreplaces each item of a list with your own markup, usually built from those parts. The list still wraps each item in its<li>.childrenreplaces the default layout inside a card (BlogPostCard,ChangelogItem,HelpCategoryCard,HelpArticleCard,RoadmapPostCard), the text of the roadmap's title and excerpt parts, and the label of theWhatsNewbutton, next to its count.renderLinkswaps the default<a>for your router's link component.renderImageswaps the default<img>fornext/imageor your image CDN.
Title parts link only the title unless you pass stretched, which makes the nearest positioned ancestor clickable as a whole; the default cards do that.
Every component also forwards ref and native attributes, such as id, aria-* and data-testid, to its root element. Where a component sets role, aria-label or aria-current itself, its own value wins; rename those through labels.
Level 4: headless hooks
The interactive components are built on hooks you can use with markup of your own: useReactions, useActiveHeading, useWhatsNew and useHelpSearchBox. They keep the behaviour, the state and the requests, and render nothing. Their options and results are described in the article for each area.
State as data attributes
State is exposed as data-* attributes, so it can be styled without JavaScript. Attributes that are true or false are always present, as "true" or "false", so select [data-active='true'], not [data-active].
data-typeon changelog items, type badges, type filter items, and reaction and vote buttons.data-typeon announcement pills, rows and articles and on their type badges and icons:info,warning,critical,promotion,maintenanceornewFeature, withinfofor a missing or unknown type.data-openableonAnnouncementPillanddata-selectableon announcement rows,"true"when the title is a button.data-has-linkon cards and changelog items, anddata-has-coveron blog cards and changelog items.data-activeon tag filter, type filter, roadmap category filter and category navigation items and on table of contents links,"true"on the active one.data-depthon table of contents items and links,data-pageon pagination items.data-state(openorclosed) anddata-has-unseenonWhatsNew, anddata-unseenon its items.data-status(idle,loading,successorerror) anddata-emptyonHelpSearchBox.data-pressedon reaction and vote buttons;data-pendinganddata-erroronReactionsandHelpfulVote;data-vote(yes,noornone) onHelpfulVote.data-column-id,data-column-typeanddata-column-nameon roadmap columns, anddata-categoryon roadmap cards, category badges and category filter items.data-expandedon aBlogTagFilterwith alimit,data-overflow="true"on the tags past it, anddata-placeholder="true"on a cover box showing its placeholder. These three are set only where they apply.data-skeletonandaria-busyon skeletons.
Labels and translation
Every visible text comes from a labels object, one per area: BlogLabels, ChangelogLabels, HelpLabels, EntryLabels, RoadmapLabels and AnnouncementLabels. Each component of an area takes it as labels and uses its own keys, so 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} статьи' } }}
/>
Labels | Keys, with their English defaults |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
Dates
Dates are absolute, such as Sep 15, 2026, and computed in UTC, so the server's HTML and the browser's first render agree. locale changes the format. formatDate replaces it entirely, for example with relative dates your app computes after hydration.
Slots reference
root is always the component's own class; each slot adds the root class plus -<slot>.
Component | Root class | Slots |
|---|---|---|
|
| none |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
every skeleton | its list's root class ( |
|
titleLink, coverImage, coverPlaceholder, authorAvatar and authorName add no class of their own. RoadmapSkeleton also takes columnClassNames. Parts carry their own class and slots as well, such as kookee-blog-post-title with its link slot, BlogPostCover with image and placeholder, the author parts with avatar and name, or kookee-changelog-type-badge. The marks Highlight renders have kookee-highlight. Other two-word slots get a class in kebab case: titleButton on an announcement row is kookee-announcement-item-title-button. The announcement type badges and icons carry kookee-announcement-type-badge and kookee-announcement-type-icon.
What stays stable
Props, slot names, class names, label keys, data-* attributes, CSS variables and hook results are public, and change only in a major release. How elements are nested inside a component is not, and may change in a minor release. If your CSS depends on a particular structure, compose the exported parts instead of relying on the default nesting.
Continue reading
React components for your blog, changelog and help center: every area, its import paths and how links work.
Show announcements in your React app: the announcement pill, list and article, wired into your own dialog.
Fixing problems with the React components: missing styles, Tailwind import order and the other usual causes.