Drop-in Web Components for NotionKit CSS
88 vanilla JavaScript custom elements wrapping NotionKit's calm, document-centric workspace look.
Shadow DOM · Native form participation · Pixel parity with the class markup · Zero dependencies.
🌐 Live Demo · 📖 Documentation · 🎨 Showcase · 📓 NotionKit CSS · 🚀 NotionKit Web · 🤖 SKILL.md
NotionKit Elements is the companion library to NotionKit CSS. It provides 88 Web Components – from buttons, form controls and the date picker over the sidebar, page tree and page shell with its properties to the settings modal, the bottom sheet, the side peek, dialogs, tooltips, ⌘K palette, database views – a calendar and a gallery among them – with Notion’s toolbar and AI thread – that encapsulate the class markup of the foundation into simple, declarative custom elements.
It is the app layer of the NotionKit family – three layers, one design language: NotionKit is the pure-CSS foundation, NotionKit Elements wraps it into web components for application UIs, and NotionKit Web is the Astro template for complete websites on top of the same foundation. The word stem stays the same across the layers: .nk-callout becomes <nk-callout>, a modifier class becomes an attribute, a state class becomes a boolean attribute.
<!-- Before: 6 elements, 6 classes -->
<div class="nk-field">
<div>
<div class="f-label">Email notifications</div>
<div class="f-desc">Shown next to your comments.</div>
</div>
<div class="f-control"><button class="nk-switch" role="switch" aria-checked="true"></button></div>
</div>
<!-- After: 2 elements, 0 classes -->
<nk-field label="Email notifications" desc="Shown next to your comments.">
<nk-switch checked></nk-switch>
</nk-field>| Feature | Details |
|---|---|
| 🎯 Pixel parity | Every element renders identically to its class markup – verified by a Playwright pixel test in light and dark; the reference app differs by 0.00 % from the class version |
| 🔌 Shadow DOM | Each root adopts the NotionKit components sheet via adoptedStyleSheets; tokens are inherited from the document, so one :root { --nk-accent: … } re-brands everything |
| 🧩 88 Elements | Forms with copy field, image picker and date picker, content blocks with bookmarks, app shell & tree with the ☰ drawer and the collapsing sidebar, page shell with properties, panels, avatars and steps, overlays (modal, sheet, dialog, resizable side peek, tooltip, ⌘K palette, menus that float in and become sheets on a phone, popover, emoji picker, toast), database table, board, list, calendar & gallery with toolbar and filter pills, comments, AI thread |
| 🎛️ Form participation | Input, textarea, select, switch, check, radio, slider, segmented and model card work natively with <form> via ElementInternals; radios group across shadow roots |
| 🌗 Theme sync | One observer mirrors data-theme on <html> into every element |
| 🪶 Lightweight | 292 KB minified / 59 KB gzipped (IIFE, foundation sheet included), no external dependencies |
| 📦 Five bundle shapes | IIFE, minified IIFE, ESM, minified ESM with a source map (esm.min.js), and per-component ESM entries on a stable base.js, which import NotionKit's sheet instead of carrying a copy |
| 🤖 AI-ready | SKILL.md with copy-paste markup, six app skeletons and the rules – an agent given only that file built a working app in the release test |
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@jungherz-de/notionkit@1.19.1/notionkit.min.css">
<script src="https://cdn.jsdelivr.net/npm/@jungherz-de/notionkit-elements@1.19.0/dist/notionkit-elements.min.js"></script>
</head>
<body class="nk-body">
<nk-field label="Display name" desc="Shown next to your comments">
<nk-input name="name" value="Ada Lovelace"></nk-input>
</nk-field>
<nk-btn variant="primary">Save</nk-btn>
</body>
</html>npm install @jungherz-de/notionkit-elements @jungherz-de/notionkitimport '@jungherz-de/notionkit/notionkit.css';
import '@jungherz-de/notionkit-elements'; // everything
import '@jungherz-de/notionkit-elements/components/nk-btn.js'; // or one element (shared code: base.js)
import { componentsSheet } from '@jungherz-de/notionkit-elements'; // the sheet the elements adopt, for your own viewsThe per-component files import NotionKit's sheet as @jungherz-de/notionkit/notionkit-styles.js rather than carrying a copy, so a project that adopts the sheet in its own views has it once. A bundler resolves it from node_modules; a build-free page adds one import-map entry:
<script type="importmap">{ "imports": { "@jungherz-de/notionkit/notionkit-styles.js": "/node_modules/@jungherz-de/notionkit/notionkit-styles.js" } }</script>Without a build step, an import map can point at the minified module instead: "@jungherz-de/notionkit-elements": "/node_modules/@jungherz-de/notionkit-elements/dist/notionkit-elements.esm.min.js" – the same exports as notionkit-elements.esm.js, its source map beside it.
Do not mix the full bundle with the per-component files: each brings its own NkElement. With the bundle, take componentsSheet and NkElement from the bundle (NotionKitElements.componentsSheet from the <script> build).
Two prerequisites, always: notionkit.css on the document (the peer dependency, >= 1.19.1; 1.19.1 keeps a toast with an × at its height, 1.19.0 brings the toast with an action, 1.18.0 brings a bar and its label in one row, stacked properties that keep to their card on a phone and icons in the copy field, 1.17.0 brings a step's label and a gallery card as links, 1.16.0 brings --nk-block-space and flush for blocks in a column, keeps a wide progress bar and a stretched button's label in shape and lays floating menus above dialogs, 1.15.0 brings table text with a tone and a second line, 1.14.0 brings the gallery view and a banner action that stands at the right edge again, 1.13.0 keeps fields inside narrow panels, leaves a page without icon its padding under the cover and makes cover heights tokens, 1.12.0 brings a tag or button beside a panel's title, lists and grids without outer margin, text values, steps with states in any order and in a row, scrolling tabs, a cap for full pages and buttons per table row, 1.11.0 keeps a floating menu inside an open modal or dialog in place and inside the window and brings the banner's action as a button, fields that share a row and tooltips of several lines, 1.10.1 lets a disabled button show its tooltip, 1.10.0 brings the collapsing sidebar, the resizable side peek, the dialog and the tooltip, 1.9.0 the date picker and the calendar view, 1.8.0 the side peek, the bookmark, the copy field and pictures on the profile row, 1.7.0 the sheet, menus that float in and become sheets on a phone, the sidebar drawer, the database toolbar with filter pills and the steps, 1.7.1 minified files that carry all of it, 1.6.0 page properties, page options, list view, panels, prose and the avatar, 1.5.1 the safe areas, 1.5.0 the Notion-2025 tokens, the nine tag colours and the filled inputs; from 1.5.0 on, Elements and the foundation share one version number – the elements are built and tested against the same release) and class="nk-body" on <body>. The elements ship no visual CSS of their own – every rule comes from the foundation. The bundle injects the design tokens once as @layer notionkit-defaults, so any plain :root { --nk-accent: … } of yours re-brands every element in both themes.
| Group | Elements |
|---|---|
| Forms & controls | nk-btn nk-input nk-textarea nk-select nk-switch nk-check nk-radio nk-slider nk-field nk-fields nk-copy-field nk-image-picker nk-calendar |
| Content | nk-tag nk-progress nk-callout nk-bookmark nk-divider nk-heading nk-toggle nk-todo nk-kbd nk-code nk-quote |
| App shell & navigation | nk-app nk-sidebar nk-workspace-switcher nk-section-label nk-tree nk-tree-item nk-topbar nk-breadcrumb nk-theme-toggle nk-tab-bar nk-tab-bar-item |
| Page shell & blocks | nk-page nk-page-cover nk-page-title nk-page-actions nk-props nk-prop nk-panels nk-panel nk-avatar nk-block-host nk-banner nk-empty nk-skeleton nk-synced nk-tabs nk-tab nk-segmented nk-steps nk-stats nk-stat nk-avatar-group nk-mention nk-template-btn nk-model-card nk-profile-row nk-danger-zone nk-member-list nk-member-row |
| Overlays | nk-modal nk-sheet nk-dialog nk-peek nk-tooltip nk-settings-pane nk-settings-user nk-cmdk nk-menu nk-menu-item nk-pop nk-emoji-picker nk-toast |
| Data & collaboration | nk-database nk-table-view nk-board-view nk-list-view nk-calendar-view nk-gallery-view nk-filter-bar nk-comments nk-comment nk-ai-thread nk-ai-msg nk-ai-input-row (+ the exported renderPropertyCell()) |
Static content goes in through attributes and slots, dynamic data through properties (tree.data, database.rows, palette.commands). Interactions fire custom events with fixed names: nk-select, nk-change, nk-view-change, nk-command, nk-toggle, nk-submit, nk-action. No fetching, no two-way binding – the elements render what they get.
The texts the elements bring along – button labels, placeholders and the names a screen reader announces – come in English and German. The language is the element's own: its nearest lang attribute, across shadow roots, else <html lang>; de, de-DE or de-AT give German, everything else English. An attribute on the element still wins, so a label with a meaning of its own stays (choose-label="Upload logo").
import { setStrings, builtInStrings } from '@jungherz-de/notionkit-elements'; // or from '…/base.js' with single elements
setStrings({ newPage: '+ New course' }); // every language
setStrings({ copy: 'Copier', close: 'Fermer' }, 'fr'); // a language of its own
builtInStrings('de'); // the keys and the German texts, a copyThe elements on the page take new texts at once, after setStrings() and when <html lang> changes. An element of your own built on NkElement reads the same dictionary with this.str('key') – your keys too – and applies it again in onStringsChanged(). Percentages follow the language as well: a progress value of 45 reads 45% in English and 45 % in German, with a no-break space, as Intl.NumberFormat writes it (a column's locale first). Dates and month names already did.
| Key | Element | Attribute | English | German |
|---|---|---|---|---|
copy |
nk-copy-field |
copy-label |
Copy | Kopieren |
copied |
nk-copy-field |
copied-label |
Copied | Kopiert |
show |
nk-copy-field |
show-label |
Show | Zeigen |
hide |
nk-copy-field |
hide-label |
Hide | Verbergen |
uploadImage |
nk-image-picker |
choose-label |
Upload image | Bild hochladen |
changeImage |
nk-image-picker |
change-label |
Change image | Bild ändern |
remove |
nk-image-picker |
remove-label |
Remove | Entfernen |
close |
nk-peek |
close-label |
Close | Schließen |
resize |
nk-peek |
resize-label |
Resize | Breite ändern |
today |
nk-calendar, nk-calendar-view |
today-label |
Today | Heute |
previousMonth |
nk-calendar, nk-calendar-view |
prev-label |
Previous month | Voriger Monat |
nextMonth |
nk-calendar, nk-calendar-view |
next-label |
Next month | Nächster Monat |
clear |
nk-calendar |
clear-label |
Clear | Leeren |
time |
nk-calendar |
time-label |
Time | Uhrzeit |
filter |
nk-filter-bar |
filter-label |
Filter | Filter |
sort |
nk-filter-bar |
sort-label |
Sort | Sortieren |
addFilter |
nk-filter-bar |
add-label |
+ Filter | + Filter |
removeFilter |
nk-filter-bar |
remove-label |
Remove filter | Filter entfernen |
search |
nk-filter-bar |
placeholder |
Search … | Suchen … |
closeSidebar |
nk-sidebar |
collapse-label |
Close sidebar | Seitenleiste schließen |
newPage |
nk-table-view, nk-list-view, nk-gallery-view |
new-row-label |
+ New page | + Neue Seite |
commentPlaceholder |
nk-comments |
placeholder |
Comment … | Kommentieren … |
send |
nk-comments, nk-ai-input-row |
send-label (comments) |
Send | Senden |
askPlaceholder |
nk-ai-input-row |
placeholder |
Ask something … | Frag etwas … |
commandPlaceholder |
nk-cmdk |
placeholder |
Search or type a command … | Suchen oder Befehl eingeben … |
emojiPlaceholder |
nk-emoji-picker |
placeholder |
Search… | Suchen … |
toggleTheme |
nk-theme-toggle |
title |
Toggle light / dark | Hell / Dunkel |
synced |
nk-synced |
badge |
⟳ synced | ⟳ synchronisiert |
message |
nk-ai-input-row |
– | Message | Nachricht |
comment |
nk-comments |
– | Comment | Kommentar |
commandPalette |
nk-cmdk |
– | Command palette | Befehlspalette |
searchEmoji |
nk-emoji-picker |
– | Search emoji | Emoji suchen |
settings |
nk-modal |
– | Settings | Einstellungen |
title |
nk-page-title (editable) |
– | Title | Titel |
changeIcon |
nk-page |
– | Change icon | Symbol ändern |
breadcrumb |
nk-breadcrumb |
– | Breadcrumb | Pfad |
add |
nk-tree-item |
– | Add | Hinzufügen |
more |
nk-tree-item |
– | More | Mehr |
A step of <nk-steps> with href and a card of <nk-gallery-view> whose row has an address in the field named by href-key are real links: they open in a new tab with a middle click and offer "Copy link" in the context menu. A plain left click, and Enter, fire the cancelable nk-select as before; cancel it and the browser stays, for a router of your own. A middle click or one with Cmd, Ctrl, Shift or Alt fires nothing and does what a link does. Steps and rows without an address stay buttons and cards.
- Each element's shadow root adopts the NotionKit components sheet only; tokens are inherited from the document. A
.nk-wrapperinside the root mirrorsdata-themefrom<html>– one observer for all instances. - Every host is
display: contents: the inner.nk-*element sits in the parent layout exactly where the class markup would. That is what makes the pixel parity hold – and why spacing belongs on a wrapper you own, not on the host. - Form controls are form-associated custom elements:
FormData,reset,requiredand<fieldset disabled>just work.<nk-radio>s with the samenameform a real group across shadow roots, with one tab stop and arrow keys. - Elements that copy light-DOM children (
nk-selectoptions, breadcrumb crumbs) watch them;element.refresh()is the escape hatch. Listeners are re-armed on every connect, so a moved element keeps working. ::slotted()matches only the assigned node: pass icons directly (<span slot="icon">📁</span>), never wrapped. For the same reason there is no<nk-prose>: rendered Markdown goes into a light-DOM<div class="nk-prose">, which reads the same rules as the editor adapter.- Overlays (
nk-modal,nk-sheet,nk-peek,nk-cmdk,nk-toast, a floatingnk-menu) go directly under<body>. The modal, the sheet and the palette lock scroll and make the rest of the pageinert– the side peek only as a sheet on a phone, beside the page it leaves it usable; all of them return focus to the control that had it, inside a shadow root too. Escape closes the layer that opened last: a date picker or menu opened from a dialog first, then the dialog. - The editor stays an adapter:
nk-block-hostis the shell,docs-editor.jsthe TipTap recipe (used in the demo app).<nk-editor>follows in v1.1 as an optional import, never in the core bundle.
npm install
npm run build # dist/: IIFE, minified IIFE, ESM, per-component ESM on dist/components/base.js
npm run build:all # + docs.html, showcase.html, index.html, de/, SKILL.md, llms.txt
npm test # Playwright: pixel parity vs. class markup (both themes), branding, reconnect, radio groups, light-DOM drift, forms, overlays, data views, per-component import
npm run check:coverage && npm run check:versionstools/catalog.mjs is the single source for docs, showcase, SKILL.md, llms.txt, the coverage check and the parity test. dist/ and the generated pages are committed; CI rebuilds them and fails on drift. dist/ embeds the installed NotionKit version – release order: publish the CSS package first, npm install here, rebuild, commit, tag.
| Layer | Repository | Website |
|---|---|---|
| NotionKit – CSS foundation | JUNGHERZ/NotionKit |
notionkit.jungherz.com |
| NotionKit Elements – Web Components (this package) | JUNGHERZ/NotionKit-Elements |
notionkit-elements.jungherz.com |
| NotionKit Web – Astro template for complete websites | JUNGHERZ/NotionKit-Web |
notionkit-web.jungherz.com |
npm: @jungherz-de/notionkit · @jungherz-de/notionkit-elements
MIT · Jungherz GmbH