A drag-and-drop, no-code-friendly visual builder for Slack Block Kit messages, packaged as an integration-agnostic React component.
Inspired by Slack's Block Kit Builder, reimagined as an embeddable React component you can drop into your own app.
Try the live demo → (mocked Slack — drag, drop, edit, preview)
The package owns the entire builder UX — palette, sortable preview surface, per-block popover editors, send dialog. It knows nothing about how channels are listed, who the user is, or how messages are sent. The consumer wires those concerns through callback props. A working end-to-end app is shown in block-kitchen-template.
pnpm add @tightknitai/block-kitchenModern package managers (pnpm 8+, npm 7+, yarn berry) auto-install peer dependencies — no extra command needed. The peer set is deliberately narrow: only the dependencies a typical consumer is likely to already have, where deduplication gives them a real win:
react^18 || ^19,react-dom^18 || ^19@radix-ui/react-{dialog,label,popover,radio-group,slot,tooltip}— the dialog/popover/tooltip/sheet primitives the builder uses. If you already use shadcn/ui you already have these, and peer deduplication avoids two Radix copies (which would split React context — a<TooltipProvider>from your copy can't reach a<Tooltip>from ours).lucide-react— every icon in the toolbar, palette, and editors. Universal in modern React UI stacks; peer keeps a single copy.
Everything else (@dnd-kit/*, @tiptap/*, slack-blocks-to-jsx,
@tightknitai/slack-block-kit-validator) is a regular dependency.
Most consumers don't already have these, so there's no dedupe benefit
to forcing them into a peer position — and you don't have to think
about their versions.
If your package manager doesn't auto-install peers:
pnpm add @tightknitai/block-kitchen \
react react-dom \
@radix-ui/react-dialog @radix-ui/react-label \
@radix-ui/react-popover @radix-ui/react-radio-group \
@radix-ui/react-slot @radix-ui/react-tooltip \
lucide-reactImport the stylesheet once, at app root. The builder mounts dialogs, popovers, tooltips, and the mobile palette sheet via React portals, which render to
document.body— outside the route tree. If you importstyles.cssinside a single route module, those portal contents will be unstyled on every other route that doesn't import it. Put the import in your root layout (Next.jsapp/layout.tsx, Remixapp/root.tsx, Vitesrc/main.tsx, etc.).
import { BlockKitchen } from "@tightknitai/block-kitchen";
import "@tightknitai/block-kitchen/styles.css";
export function MyBuilderPage() {
return (
<BlockKitchen
workspaceName="Acme Inc."
loadChannels={async () => {
const res = await fetch("/api/slack/channels");
return res.json();
}}
loadSendAsUserStatus={async () => {
const res = await fetch("/api/slack/me/can-send-as-user");
return res.json();
}}
onSend={async ({ channelId, blocks, sendAsUser }) => {
const res = await fetch("/api/slack/messages/send", {
method: "POST",
body: JSON.stringify({ channelId, blocks, sendAsUser }),
});
return res.json();
}}
/>
);
}| Prop | Type | Required | Description |
|---|---|---|---|
workspaceName |
string |
no | Shown in the preview chrome to mimic a real Slack message header. |
initialBlocks |
SupportedBlock[] |
no | Starting draft. If omitted, the builder starts empty. |
onChange |
(blocks: SupportedBlock[]) => void |
no | Fires on every state change. Use this to persist the draft (URL, localStorage, etc). |
onValidationChange |
(summary: ValidationSummary) => void |
no | Fires when the draft's validation verdict changes (debounced alongside the builder's own validation pass — not on every keystroke). summary is { valid, errorCount, errors }: the exact verdict the issues chip/sheet shows, always scoped to the message surface. Lets a host-owned CTA gate on validity in compose-only mode without re-running the validator. |
loadChannels |
() => Promise<{ id: string; name: string }[]> |
grouped* | Returns channels available to send to. The package never makes Slack API calls itself. |
loadSendAsUserStatus |
() => Promise<{ canSendAsUser: boolean; oauthUrl?: string }> |
grouped* | Whether the current user has a Slack user-token and can post as themselves. If canSendAsUser is false, oauthUrl is shown as a "Sign in with Slack" link. |
onSend |
(payload) => Promise<{ ok: boolean; error?: string }> |
grouped* | Called when the user submits the send dialog. Payload is { channelId, blocks, sendAsUser, extras? } (extras only when renderSendExtras is wired). |
renderSendExtras |
(ctx: SendExtrasContext) => ReactNode |
no | Renders host-defined fields inside the built-in send dialog, below the channel and identity pickers. Collect values with ctx.setExtras(patch); they arrive on onSend as payload.extras. Requires the send trio. See Extending the send dialog. |
primaryAction |
{ label, onClick, disableWhenInvalid? } |
no | Compose-only mode only: a host-owned button in the toolbar slot where "Review & send" normally sits. onClick receives { blocks, validation, loadedMessage } — the current draft, the same verdict onValidationChange reports, and the currently loaded message (null unless loading is configured and one is loaded). See Keeping the CTA in the toolbar. |
loading |
{ onLoadMessage, loadRecentMessages?, loadChannels?, initialTarget?, onLoadedMessageChange? } |
no | Opt-in: load an existing message into the editor. Adds a "Find message" toolbar entry — the user pastes a Slack permalink (or picks a recent message) and the draft is hydrated with its blocks. Loading is a composition concern, so it works in both send mode and compose-only mode. onLoadedMessageChange reports the loaded target (null on exit) so a host-owned commitment step can stay in sync. |
onUpdate |
(payload) => Promise<{ ok: boolean; error?: string }> |
no | Opt-in update-in-place mode (send mode only); sibling to onSend, but the payload carries the source channel + ts. With loading configured, a loaded message flips the primary action to a "Review & update" split button wired to it (chat.update). |
loadButtonLabel |
string |
no | Label + accessible name for the toolbar button that opens the load-message dialog. Defaults to 'Find message'. Only shown when loading is configured. |
updateButtonLabel |
string |
no | Label for the toolbar's primary button while a message is loaded for editing. It's a split button: clicking it updates the message in place; the menu beside it also offers "Send as a new message" (post the current blocks as new). Defaults to 'Review & update'. |
confirmUpdateLabel |
string |
no | Label for the update dialog's final confirm button. Defaults to 'Update message' (shows 'Updating…' while in flight). |
previewHooks |
PreviewHooks |
no | Hooks forwarded to slack-blocks-to-jsx's <Message> for resolving user / channel / emoji directives. |
customEmojis |
CustomEmoji[] |
no | Workspace custom emoji ({ name, url, alias }) the preview resolves. Entries with a url render :name: as the workspace image; alias entries (url: null) fall back to their target emoji. Render-only — never serialized into the emitted Block Kit JSON. A caller-supplied previewHooks.emoji takes precedence. |
palette |
PaletteSection[] |
no | The left-hand palette of draggable variants. Defaults to defaultPalette. Spread it to filter, reorder, or add your own pre-configured variants — see Customizing the palette. |
disabledBlockTypes |
SupportedBlockType[] |
no | Block types to hide from the palette without rebuilding it. Filters at the variant level — a section keeps any variants whose block types aren't disabled; sections that end up empty are dropped. Convenient when you want the default palette minus a few types (e.g. ['image', 'table'] for a text-only builder). |
defaultOpenSections |
boolean | string[] |
no | Which palette section headers are expanded on first paint. true (default) opens all sections; false collapses all (Slack-style); an array opens only sections whose name is in the list (e.g. ['Section', 'Actions']). The palette also has a built-in search input that expands matching sections on demand. |
showPaletteSearch |
boolean |
no | Whether the palette renders the quick-search input above the section list. Defaults to true. Set false for compact palettes (e.g. when you've passed a small custom palette) where scanning by eye is faster than typing. |
paletteSearchPlaceholder |
string |
no | Placeholder text for the palette search input. Defaults to 'Search blocks…'. Useful for localization. |
allowedSurfaces |
PreviewSurface[] |
no | Allowlist of preview surfaces ('message', 'modal', 'app_home'). Defaults to ['message'] — surface dropdown is hidden when only one surface is allowed. The first entry is the initial selection. |
showThemeControl |
boolean |
no | Defaults to true. When false, the toolbar's light/dark toggle is hidden and the theme stays at defaultPreviewTheme. Ignored when previewTheme is set (a controlled theme always hides the toggle). |
defaultPreviewTheme |
'light' | 'dark' |
no | Initial (uncontrolled) preview theme. Pass the host app's current theme so the preview opens matched to the consuming app's appearance. Ignored when previewTheme is provided. |
previewTheme |
'light' | 'dark' |
no | Controlled preview theme. When set, the preview renders in this theme, follows it reactively, and the toolbar's light/dark toggle is hidden so the host app fully owns the theme. Leave unset to keep the preview uncontrolled (seeded from defaultPreviewTheme, toggle shown). |
sendButtonLabel |
string |
no | Label and accessible name for the toolbar's Send button (which opens the send dialog). Defaults to 'Review & send' (the dialog is the review step). Override it for product-specific copy, e.g. 'Send to channel…'. |
confirmSendLabel |
string |
no | Label for the send dialog's final confirm button. Defaults to 'Send' (shows 'Sending…' while in flight). |
theme |
BrandTheme | BrandPreset |
no | Branding tokens applied to the builder chrome (toolbar, palette, popovers, dialogs). Accepts a Partial<BrandTokens> map and optional light/dark overrides. See Styling below. |
* The send trio — loadChannels, loadSendAsUserStatus, onSend — is
all-or-nothing: provide all three for the built-in send flow, or omit all
three for compose-only mode
(no send button; your app owns the send flow). Wiring only some of the three
is a type error. onUpdate and renderSendExtras require the trio;
primaryAction requires its absence (the built-in Send/Update flow owns the
toolbar's primary slot otherwise). loading is mode-agnostic — it works
with or without the trio.
Omit the send trio entirely and the builder renders no send button — it becomes a pure Block Kit editor. This is the right shape when composing is one step in a flow your app owns (a wizard, an audience picker, a scheduler): the package owns composition (authoring, preview, validation UX) and your app owns distribution (audiences, identity, scheduling, delivery). The only thing that crosses the boundary is the blocks array.
Mirror the draft with onChange, gate your own CTA with
onValidationChange, and hand toSlackBlocks(draft) to whatever comes next:
import { useState } from "react";
import {
BlockKitchen,
toSlackBlocks,
type SupportedBlock,
type ValidationSummary,
} from "@tightknitai/block-kitchen";
function ComposeStep({ onNext }: { onNext: (blocks: SupportedBlock[]) => void }) {
const [draft, setDraft] = useState<SupportedBlock[]>([]);
const [validation, setValidation] = useState<ValidationSummary | null>(null);
return (
<>
<BlockKitchen onChange={setDraft} onValidationChange={setValidation} />
{/* Your CTA, your copy, your placement — gated on the builder's own verdict. */}
<button
disabled={draft.length === 0 || validation?.valid === false}
onClick={() => onNext(toSlackBlocks(draft))}
>
Next: choose audience
</button>
</>
);
}Notes:
- The rest of the toolbar (Clear, View JSON, the issues chip, theme/surface controls) is unchanged — users can still inspect problems in the issues sheet; only the moment of commitment moves into your app.
onValidationChangereports the same verdict the issues sheet displays (validated against themessagesurface, like Send), so your CTA and the in-builder issue count can never disagree.- Loading an existing message works
here too — pass
loadingand the "Find message" entry appears. The loaded target (channel + ts + editability verdict) reaches your app viaprimaryAction'sloadedMessagecontext andloading.onLoadedMessageChange, so your own commitment step can offer "update in place" alongside "post as new". - The built-in update flow (
onUpdate) is unavailable in compose-only mode: dispatching achat.updateis inherently bound to a channel + timestamp + token, so it only exists alongside the send integration.
The example above renders its CTA outside the builder. If you want the button
to sit where "Review & send" normally sits — say a message drafter whose
primary action is "Save template" — pass primaryAction:
<BlockKitchen
primaryAction={{
label: "Save template",
onClick: ({ blocks, validation }) => openSaveModal(blocks, validation),
// Send-style gating is opt-in. Default is enabled-while-invalid: a
// drafter usually allows committing a work-in-progress draft and
// surfaces the verdict in its own flow instead.
disableWhenInvalid: false,
}}
/>onClick receives the current draft in the builder's native format (the same
shape onChange reports — persist that to re-open the draft later; run it
through toSlackBlocks for the Slack-ready payload) plus the validation
verdict onValidationChange would report. The button is never disabled for
an empty draft; gate on blocks.length in onClick if you need that.
If your custom flow ends in something send-shaped, you don't have to rebuild the built-in dialog's machinery — the send-flow primitives are exported:
SendDialog— the built-in dialog as a standalone component (SendDialogProps): channel loading/error states, bot-vs-user identity picker, OAuth hand-off, in-flight and failure states. Mount it against your own trigger.useSlackSignIn(loadStatus, { open, enabled })— the OAuth sign-in state machine behind the identity picker: fetches user-token status on open, refreshes on window focus, and background-polls after the OAuth tab opens until the token appears.SlackSignInButton— the "Sign in with Slack" button with its polling spinner, for wiringuseSlackSignIninto your own dialog.
When the built-in send flow is almost right — you want the channel picker,
identity picker, and sending states, plus a few controls of your own (a
cross-post toggle, a custom sender name) — pass renderSendExtras instead of
rebuilding the dialog. It renders below the built-in fields, and whatever you
collect via setExtras arrives on onSend as payload.extras:
<BlockKitchen
loadChannels={loadChannels}
loadSendAsUserStatus={loadSendAsUserStatus}
renderSendExtras={({ extras, setExtras }) => (
<>
<label>
<input
type="checkbox"
checked={Boolean(extras.alsoPostToIntranet)}
onChange={(e) => setExtras({ alsoPostToIntranet: e.target.checked })}
/>
Also publish to the intranet
</label>
<label>
Custom sender name
<input
type="text"
value={String(extras.senderName ?? "")}
onChange={(e) => setExtras({ senderName: e.target.value })}
/>
</label>
</>
)}
onSend={async ({ channelId, blocks, sendAsUser, extras }) => {
await api.send({ channelId, blocks, sendAsUser, ...extras }); // your code
return { ok: true };
}}
/>Notes:
- The
extrasobject is owned by the dialog: it resets every time the dialog opens (alongside the channel and identity pickers), andsetExtras(patch)shallow-merges. Render controlled inputs fromextras. - The context also carries the currently selected
channelId(nulluntil channels load) andsendAsUser, so extras can react to them — e.g. only offer cross-posting for certain channels. payload.extrasis present iffrenderSendExtrasis wired, so existingonSendhandlers never see a new key.- The dialog renders in a portal attached to
document.body, so CSS that relies on ancestor selectors from your app's DOM won't reach slot content — style it directly (inline, CSS modules, utility classes). - For customizations that generalize something the dialog already models (identities, channels), prefer proposing a first-class prop over routing them through extras; the slot is for genuinely host-specific concerns.
By default the builder starts from a blank canvas. Pass loading to let
users load an already-posted message's blocks into the editor — paste a
permalink, or pick from a "recent messages from this app" list. Loading is a
composition concern, so it works in both send mode and
compose-only mode. The
package stays integration-agnostic: it makes no Slack calls and computes
nothing about who can edit; the host does both.
<BlockKitchen
/* …send trio, or nothing (compose-only)… */
loading={{
// Host parses the pasted permalink, fetches the message, and returns a
// verdict. `chat.update` only edits a message authored by the calling
// token, so the host decides: bot message → 'bot', the user's own
// message → 'user', anything else → not editable.
onLoadMessage: async ({ link }) => {
const msg = await fetchMessageFromPermalink(link); // your code
if (!msg) return { ok: false, reason: "Couldn't find that message." };
if (!msg.blocks?.length) return { ok: false, reason: 'This message has no editable blocks.' };
// `username` + `iconUrl` are optional; when present they show in the
// preview header instead of the generic workspace name/avatar.
const author = { username: msg.authorName, iconUrl: msg.authorAvatarUrl };
if (msg.appId === MY_APP_ID)
return { ok: true, channelId: msg.channel, channelName: msg.channelName, ts: msg.ts, blocks: msg.blocks, editableVia: 'bot', ...author };
if (msg.userId === currentUserId)
return { ok: true, channelId: msg.channel, channelName: msg.channelName, ts: msg.ts, blocks: msg.blocks, editableVia: 'user', ...author };
return { ok: false, reason: 'Only messages your app or you posted can be edited.', blocks: msg.blocks };
},
// Optional: adds a "recent messages from this app" picker beside the paste
// input. The user first picks a channel, then this is called with that
// `channelId` so the lookup scans only one channel. These are
// editable-by-construction (the app authored them), so picking one loads
// it directly (no verdict needed).
loadRecentMessages: async (channelId) => {
const msgs = await fetchRecentAppMessages(channelId); // your code
return msgs.map((m) => ({
channelId: m.channel,
channelName: m.channelName,
ts: m.ts,
blocks: m.blocks,
editableVia: 'bot', // defaults to 'bot' if omitted
label: m.preview // one-line preview shown in the picker row
}));
}
// In send mode the recent picker's channel list reuses the send trio's
// `loadChannels`. In compose-only mode there is no trio — pass
// `loadChannels` here too, or the picker is hidden.
}}
/>- On
ok, the builder hydrates withblocksand shows a loaded banner ("References an existing message in #channel"), with the author's name/avatar in the preview header. - On
{ ok: false, reason }, the load dialog renders the reason inline and offers Open as a new message instead. Passblockson the failure result to hydrate the draft for that fallback. loading.onLoadedMessageChangereports the loaded target — andnullwhen the user exits it — so host state stays in sync (most useful in compose-only mode, where your app owns what happens next). It can re-fire for the samechannelId+tswhen the host's verdict or metadata changed (e.g.editableViaflipping after a Slack sign-in), so key host state on the full target, nottsalone.- The banner's "Switch to a new message" exit is mode-aware: in send mode it clears the canvas and reopens the load dialog (the edit-centric "pick a different message" flow); in compose-only mode it only detaches the reference and keeps the draft — loading seeded a composition, and a misclick must not destroy it.
In send mode, pair loading with onUpdate to dispatch a chat.update for
the loaded message. The primary action flips to a "Review & update" split
button (the menu also offers "Send as a new message"), the destination is
locked to the source channel, and the post-as identity is fixed to the
verdict's editableVia (the 'user' path reuses loadSendAsUserStatus for
the "Sign in with Slack" gate).
<BlockKitchen
/* …send trio… */
loading={{ onLoadMessage }}
// Sibling to onSend; carries the source channel + ts. `asUser` follows
// the verdict's `editableVia`.
onUpdate={async ({ channelId, ts, blocks, asUser }) => {
await chatUpdate({ channel: channelId, ts, blocks, asUser }); // your code
return { ok: true };
}}
/>With loading alone (no onUpdate), a loaded message can still be posted
as a brand-new message via the regular Send flow — there's just no in-place
update. In compose-only mode onUpdate doesn't exist; implement your own
update against the target from loadedMessage / onLoadedMessageChange.
Migrating from the pre-split
editingbundle (≤ 0.9.x): the load fields move toloadingunchanged, andonUpdatebecomes a top-level prop (theeditingwrapper is gone).// Before <BlockKitchen editing={{ onLoadMessage, onUpdate, loadRecentMessages }} /> // After <BlockKitchen loading={{ onLoadMessage, loadRecentMessages }} onUpdate={onUpdate} />
The default palette ships with curated presets for every supported block type. To narrow what's available, or add your own pre-configured variants (e.g. a "Help footer" section), pass a palette array. Define it at module scope (or wrap in useMemo) so it stays referentially stable across renders.
import {
BlockKitchen,
defaultPalette,
type PaletteSection,
} from "@tightknitai/block-kitchen";
const PALETTE: readonly PaletteSection[] = [
...defaultPalette.filter((s) => s.blockType !== "input"),
{
name: "Company presets",
blockType: "section",
variants: [
{
id: "help_footer",
label: "help footer",
factory: () => ({
type: "section",
text: { type: "mrkdwn", text: "Need help? Reach out in <#C0HELP>." },
}),
},
],
},
];
<BlockKitchen palette={PALETTE} {...rest} />;Variant ids must be unique across the array — the drag-drop lookup keys by id.
The package is deliberately decoupled from any Slack SDK or backend. It does not import HTTP clients, OAuth libraries, or workspace-state systems. Everything I/O-shaped is brokered through props — and the send flow itself is optional: see compose-only mode when your app owns the moment of commitment.
Helpers and send-flow primitives also exported:
import {
toSlackBlocks, // strips builder-only fields (e.g. header `level`) before sending
encodeBlocksToString, // base64url-encode a blocks array (for URL state)
decodeBlocksFromString,
defaultPalette, // the built-in palette — spread to customize
SendDialog, // the built-in send dialog, standalone (bespoke send flows)
useSlackSignIn, // the OAuth sign-in state machine behind the identity picker
SlackSignInButton, // the "Sign in with Slack" button + polling spinner
} from "@tightknitai/block-kitchen";
import type {
SupportedBlock,
SupportedBlockType,
BlockKitchenProps,
BlockKitchenBaseProps, // shared props
BlockKitchenSendProps, // the send trio + `onUpdate` + `renderSendExtras`
LoadingConfig, // load an existing message (works in both modes)
LoadedMessage, // the loaded target: channel + ts + verdict
BlockKitchenComposeOnlyProps, // the trio explicitly absent + `primaryAction`
PaletteSection,
PaletteVariant,
SendPayload,
SendResult,
SendDialogProps,
SendExtrasContext,
PrimaryActionConfig,
PrimaryActionContext,
ChannelOption,
SendAsUserStatus,
ValidationSummary,
PreviewHooks,
} from "@tightknitai/block-kitchen";The builder is frontend-only. For a full app that handles OAuth, channel listing, and chat.postMessage, see block-kitchen-template — a Vite + React SPA on Cloudflare Workers that wires this package to slack-hono on the backend.
A searchable emoji picker (search, categories, skin tone, recents) is built into the rich-text WYSIWYG and structured editors and the section / header / markdown / button-label fields. The standard set is sourced from emoji-datasource — the same iamcal codenames Slack recognizes — and resolved by Unicode codepoint, so inserted emoji round-trip to Slack without becoming blank :name: text. Skin-toned emoji emit Slack's skin_tone (rich text) / :name::skin-tone-N: (plain/mrkdwn) shape. The dataset is loaded lazily the first time a picker opens, so it doesn't add to the initial bundle.
Pass customEmojis to add your workspace's custom emoji: image entries appear as the picker's Custom category and render in the preview; alias entries fall back to their target. Custom emoji never block Send — Slack accepts unknown emoji names, so they're excluded from validation. The emitted Block Kit JSON is unchanged whether or not customEmojis is passed.
Defense-in-depth: blocks are validated against slack-block-kit-validator before send. Issues are surfaced in the issues sheet with line numbers — users can fix them inline before posting.
Ships a compiled stylesheet at @tightknitai/block-kitchen/styles.css. The styles use CSS custom properties (--background, --primary, --border, etc.) for theming. Consumers must provide values for these vars — the standard shadcn/ui token set works as-is.
import "@tightknitai/block-kitchen/styles.css";For consumers who don't already have a shadcn token set on :root, the theme prop is a typed shortcut that writes a subset of tokens directly:
import type { BrandTheme } from "@tightknitai/block-kitchen";
const brand: BrandTheme = {
tokens: { primary: "262 83% 58%", radius: "0.75rem" },
dark: { primary: "263 70% 75%" }
};
<BlockKitchen theme={brand} {...rest} />tokensapplies in both light and dark contexts.lightanddarkoverride per mode; the dark variant kicks in under a standard.darkancestor class (next-themes default).- Color tokens take HSL component strings (
"262 83% 58%"), matching the underlying CSS variable contract;radiustakes a CSS length. - Scope is the builder chrome only. The embedded Slack preview keeps its native Slack styling regardless of
theme; usepreviewTheme(controlled) ordefaultPreviewTheme(uncontrolled) for the preview's light/dark appearance.
The lower-level CSS-variable contract above keeps working; the theme prop simply layers on top of it.
Fonts are deliberately not part of BrandTheme. The builder sets no font-family of its own (aside from font-mono on the JSON viewer, which is intentional), so it inherits whatever the host page declares on <html> or <body>. Set your brand typography globally and the builder will pick it up automatically — no additional configuration needed. The Slack preview surface continues to render with Slack's own typography via slack-blocks-to-jsx.
The builder is client-only by design — it uses drag sensors, contentEditable
(TipTap), portals, and useEffect-driven state. It cannot be statically
rendered on the server. The component still ships fine inside SSR/SSG
frameworks; just mark its tree as client-side.
- Next.js (App Router) — put
'use client'at the top of the file that renders<BlockKitchen>. Importstyles.cssfromapp/layout.tsx(the root layout) so portal content stays styled on every route. - Next.js (Pages Router) — render the component inside a page
module; the bundled-client default works. Import
styles.cssfrompages/_app.tsx. - Remix / React Router — render inside any route component;
put the
styles.cssimport inapp/root.tsx. If you ship the builder on a single route, import the stylesheet there and inroot.tsxso portals on other routes don't render unstyled. - Vite SPA — import
styles.cssonce fromsrc/main.tsx. - Astro — load the React component with
client:only="react".
The package exports React JSX with the automatic runtime, so any JSX-transform-aware bundler from the last few years works without extra configuration.
Three import paths are published:
// 1. Full builder (default)
import { BlockKitchen } from "@tightknitai/block-kitchen";
// 2. Headless helpers — no React component tree, safe for backends.
// Use this when you only need to round-trip / validate / encode
// blocks (e.g. inside a Worker that calls Slack's chat.postMessage).
import {
toSlackBlocks,
encodeBlocksToString,
decodeBlocksFromString,
} from "@tightknitai/block-kitchen/helpers";
// 3. Palette catalog — for tooling that needs the default variants
// (e.g. a Storybook story or a config generator) without pulling
// in the builder.
import {
defaultPalette,
legacyInputVariants,
extraAlertVariant,
} from "@tightknitai/block-kitchen/palette";The root entry (.) still re-exports everything from ./helpers and
./palette, so existing imports keep working — the subpaths are a tree-
shaking-friendly shortcut, not a breaking split.
The stylesheet emits all utility classes into a named cascade layer:
@layer bk-theme, bk-utilities;Per CSS Cascade Level 5, unlayered rules and rules in later-declared
layers win over bk-utilities. In practice this means a consumer who
imports both this package's styles.css and their own Tailwind output
will see their utilities win on any class-name collision (e.g. they
define bg-background differently). No action needed for that
common case.
If you want explicit control — for example, to make this package's
utilities win, or to layer them alongside a shadcn/ui token stack —
declare the order at the top of your own root stylesheet:
@layer bk-theme, bk-utilities, theme, base, components, utilities;MIT. See LICENSE.
Maintained by the Tightknit team.

