Skip to content

Changelog

Every release, and why it happened.

Read from the package’s own CHANGELOG.md at build time, so this page is the release notes rather than a retelling of them. Versions follow semver: a major is a break, a minor adds, a patch fixes.

Latest1.4.0PackagekipuiReleases6

1.4.0

MinorPatch

Ship a theme runtime, and stop the install needing an @source line.

ba704d9

`kipui/theme` — ThemeProvider, useTheme and themeScript now come from the package. They own the two theme axes: themeScript() inlines in <head> and puts the stored mode and palette on <html> before the first paint, and ThemeProvider takes over after hydration, persisting both to localStorage and following the OS while the mode is "system". Both are optional — nothing in a component reads them, so an existing switcher keeps working.

`theme.css` declares its own `@source`. It resolves relative to the file it sits in, so @import "kipui/theme.css" is now the whole stylesheet setup. The @source "../node_modules/kipui/dist/esm" line consumers used to write changed with the file's depth and broke under pnpm workspaces, and getting it wrong rendered every component unstyled with no error.

**Every *Props type is exported.** InputProps, SelectProps, TableProps and forty more were declared but unreachable, so wrapping a component meant losing its types and its documentation.

Fixed: a toast fired over an open dialog rendered behind its backdrop. theme.css declares a z-index ladder where toast (1600) sits above modal (1400); only Toast read it, while Dialog hardcoded 3000, Tooltip 9999 and Calendar 10000. All of them now share one ladder, which also removes Select and Calendar's isInModal z-index special case — popover already clears modal.

Fixed: `ToastProvider` re-rendered every `useToast()` consumer on every toast. Its context value was a fresh object each render, and the provider wraps the whole app. Same unmemoized value fixed in Accordion, Tabs, RadioGroup and Pagination.

Fixed: `AccordionTrigger` kept `aria-controls` pointing at an unmounted panel while closed, which axe reports as aria-valid-attr-value. AccordionItem now also carries a real data-state, replacing an attribute that was always undefined.

**New: Dropdown** — a portalled menu hung off a trigger, as kipui/dropdown.

7033180

Fourteen parts: Dropdown, DropdownTrigger, DropdownContent, DropdownItem, DropdownCheckboxItem, DropdownRadioGroup, DropdownRadioItem, DropdownLabel, DropdownGroup, DropdownSeparator, DropdownShortcut, DropdownSub, DropdownSubTrigger and DropdownSubContent, with every *Props type and DropdownSide/DropdownAlign exported alongside them.

  • Checkable and radio rows, submenus to any depth, icons and keyboard shortcuts, and typeahead that jumps to a row as you type its label.
  • A roving `aria-activedescendant` rather than moving DOM focus, so the trigger keeps focus and the menu still reports the active row.
  • `alignOffset` on submenu content, which pulls a submenu back across the panel's padding so its first row lines up with the row that opened it.

Anchoring and highlight state are now shared. Select's positioning moved into lib/use-anchored-position and its highlight state into lib/highlight-store, so the two surfaces cannot drift apart — a fix to how one of them stays inside the viewport is a fix to both.

Fixed: a menu appeared instead of opening. Both surfaces mounted with initial={false}, so framer animated from whatever was already in the DOM — and when the coordinates and the reveal flag land in one commit, that is the finished state. Submenus hit it every time, since they set open and visible together. The surfaces now declare a real starting state, presence follows the reveal flag through AnimatePresence, and the unmount waits past the exit so its last frames are not cut. Enter is a spring on scale and offset with the fade on a tween, exit accelerates away, and prefers-reduced-motion keeps the fade alone.

Fixed: submenus sat about a hundred pixels off their own row. Nothing renders before there are coordinates, so the commit that turns a submenu on is the first moment it can be measured — and the layout effect was not watching that commit.

Select gets both motion fixes, and its radio rows can now carry a tick instead of a dot.

**New: Drawer** — a panel that comes in from an edge, as kipui/drawer.

ba704d9

Same contract as Dialog (controlled open/onClose, portal, focus trap, escape, scrim, title/description/footer) with the parts a dialog does not have:

  • Four sides. left, right, top, bottom. Horizontal sizes are widths, vertical ones are ceilings — a short sheet is as tall as its content.
  • Seven animations. slide, spring (overshoots and settles), smooth (long expo tail), scale, fade, reveal (a clip-path wipe: the panel never moves, the window onto it does) and none. prefers-reduced-motion drops all of them to a short fade.
  • Three appearances. default flush to the edge, floating inset with a gap, glass translucent.
  • Swipe to close. Drag starts on the panel's chrome — grab handle, header, footer, padding — and never inside the scrolling body or on a control, so it cannot steal a scroll or swallow a click. Closes on distance past swipeThreshold or on a flick.
  • The panel reaches the window edge. The overlay is 100vw, not inset: 0 — a fixed element sized by inset stops at the initial containing block, which excludes the page's scrollbar, so an edge-anchored panel used to stop a bar's width short and show the page's scrollbar in the gap beside it. The page's own scrollbar is never hidden or restored: nothing appears or disappears when the drawer opens.
  • An overlay scrollbar. The body's thumb is drawn on top of the content rather than in a gutter beside it, so it costs no layout width and the text runs full-bleed underneath. Every engine lays the native bar out inside the scroll box — ::-webkit-scrollbar restyles that gutter, it cannot remove it, and overflow: overlay is gone — so the native bar is hidden and the thumb is positioned from the track: it fades in while scrolling, stays up on hover, and can be dragged.
  • `scaleBackground`. Shrinks the page behind the sheet, iOS-style. Opt-in: it transforms the one element marked data-drawer-wrapper and does nothing if there isn't one.

New: `PageScrollbar` — the page's scrollbar as an element instead of browser chrome, as kipui/page-scrollbar. Mount it once near your root.

A native scrollbar is painted by the browser above every element on the page and outside the stacking order, so an edge-anchored overlay cannot cover it at any z-index. The two usual ways out are both visible to the reader: leave it there and the panel stops short of the window with a strip of page beside it, or hide it while the overlay is open and a scrollbar disappears and comes back on every open. This draws one in its place — proportional, draggable, always on screen while the page overflows — on the --z-fixed rung, so a Drawer covers it like anything else and nothing has to be taken away for that to be true. Apps that don't mount it keep the platform's bar exactly as it was.

The background scroll lock moved out of Dialog into a shared module both overlays use. Two private copies would have meant two wheel handlers, each treating the other's scroll area as page scroll — a drawer opened over a dialog would have had a body that refused to scroll.

**Fixed: a field painted its own background under the page's text.** Input, Textarea and Calendar's field set bg-background but no text-foreground, so the value inherited its colour from whatever enclosed the field. That is right only while the theme lives on the document root — put the field inside a data-theme or .dark subtree and it drew a dark background beneath dark text.

7033180

Fixed: `Calendar`'s popup animated through a JS library it did not need. It was a motion.div on a fixed 150ms tween, which is a CSS transition with a cubic-bezier; only the unmount delay actually required React, and usePresence supplies that in about forty lines. This surfaced two latent bugs: Tailwind v4 sets scale and translate as their own properties rather than through transform, so transition-[opacity,transform] would have faded the popup and jumped everything else — and one ease-out curve used in both directions spends nearly all its travel in the first few frames, which is what you want arriving and exactly what makes leaving read as a cut. Easing is now per state.

Fixed: `Calendar`'s header truncated its own labels. The select header shared 174px between two triggers that wanted 198px, so "September" and "2026" both ended in an ellipsis. Month labels are the Intl short form now, the year box is fixed at a width four digits fit in, and both triggers gave up 4px of padding. The default header had the opposite problem: its label is a flex item, so it was squeezed to its longest word and wrapped onto two lines, changing the popup's height from month to month. It is whitespace-nowrap, with the nav buttons at size-7 to pay for the space.

Fixed: the active `LineNav` row overlapped its neighbour. Activation added font-semibold, which also adds width, so a label that fitted on one line reflowed onto two — inside a list of fixed-height rows. The active row is marked with colour now; the indicator already marks the position.

Fixed: `SimpleSelect` options had too much padding on the right, which left the tick sitting away from the edge it lines up with.

`clampAxis` is exported from lib/use-anchored-position. Calendar positions itself rather than going through the hook, and an unclamped popup is not merely misplaced: portalled into body at document coordinates, one that runs past the right edge grows the document and the page sprouts a horizontal scrollbar for as long as it is open. Two copies of that arithmetic is how it regresses in one of them.

Point the package's metadata at the repository a reader can actually open.

d7dc8f4

repository and bugs named joypaul3592/kipui, which is private — so the "Repository" and "Report issue" links on the npm page both landed on a GitHub 404. They now name kipui-community, which is where every public link on the site already points.

The keyword list grew with the terms people search npm for and this package answers to (react19, component-library, ui-library, a11y, typescript, shadcn-alternative). No code changed.

1.3.0

Minor

Add a subpath for every component, and fix a webpack build that dragged the library in.

def4b18

Shipping a file per module in 1.2.0 fixed Turbopack — one <Button/> went from +77.3 kB gzip to +9.8 kB — but it moved the problem onto webpack, where the same page went the other way: +10.4 kB to +76.5 kB, framer-motion back in the bundle, Select.js and Calendar.js in a page that renders neither.

The barrel is the reason. Without a directive of its own it is a server module that imports twenty-six client modules, and Next's webpack builder turns every "use client" module it finds in the server graph into a client entry whether the export is used or not. Turbopack does not do this.

Putting "use client" back on the barrel fixes webpack and costs 67 kB gzip on Turbopack, where the barrel then becomes one client entry carrying all seventy-five exports — the 1.1.0 failure again. Measured, not assumed. So the barrel keeps no directive, and there are two ways past it instead:

  • `kipui/button`, `kipui/table`, `kipui/radio-group` — every component now has its own subpath, named in kebab-case after the component. Importing one reaches one file and never touches the barrel, which fixes webpack with no configuration at all: 795.7 kB raw down to 574.2 kB, identical to what the barrel directive bought, with no cost under Turbopack.
  • `experimental.optimizePackageImports: ["kipui"]` — for anyone who would rather keep importing from the root. It could not work in 1.1.0, when dist was one pre-bundled file with no re-exports to rewrite; now that the barrel is a real barrel, it does, to the same number.

The root import stays the recommended one, and is unchanged for Turbopack users. typesVersions maps the subpaths for projects still on TypeScript's legacy node resolution, which cannot see an exports map. publint and @arethetypeswrong/cli are clean across all twenty-nine entry points.

Also moved Tooltip's "use client" above the eslint-disable comment it sat under. It worked — a directive may follow comments — but it depended on that staying true through every build step, and nothing would have failed loudly if it stopped.

The installation docs now cover all of this, including narrowing @source to the components you use, which cuts the generated CSS from about 98 kB to 42 kB in exchange for a line to maintain per component.

1.2.0

Minor

Ship a file per module instead of one bundle. Turbopack now shakes the package.

7077d90

1.1.0 made the bundle shakeable and measured it with esbuild, which agreed with webpack: one <Button/> cost 10 kB gzip. Turbopack — the default in Next 16 — disagreed. A differential production build of a page containing nothing but <Button/> put kipui at +77.3 kB gzip, framer-motion included, against +10.4 kB for the same page built with --webpack. Grepping the chunk found pagination-ellipsis, tabs-indicator and alert-description: effectively all 75 exports.

The cause was the shape of the output, not the code. dist/index.js was a single 226 KB file whose first line was "use client", and Turbopack treats a bundled client module as one atom — sideEffects and /* @__PURE__ */ do not get a say. experimental.optimizePackageImports cannot help either, because it rewrites barrel re-exports and this was a pre-bundled file, not a barrel.

So the package no longer bundles. Every file under src compiles to its own file under dist, and the import graph survives into the output. A consumer that never names Toast never reaches the file that imports framer-motion — no bundler has to prove the import is unused, because it never sees it.

Measured the same way, on Turbopack: +77.3 kB gzip → +9.8 kB. framer-motion is absent from the client bundle entirely. Webpack is unchanged at ~10 kB, so both bundlers now agree.

The "use client" boundary moves with it. It was one banner over the whole package; it is now one directive per file, on the components that actually need it. Table, Breadcrumb, Kbd, Label, Field, Separator and Skeleton have none, so they render as Server Components and contribute zero client JavaScript — verified against a server page that renders a Table and a Kbd and ships neither.

Two things this changes for consumers:

  • The `@source` line should now point at `node_modules/kipui/dist/esm`. The old path still works but scans the CJS mirror as well, doubling the work for the same output. The install and theming docs are updated.
  • dist is now dist/esm and dist/cjs, wired through exports. Nothing imports those paths directly — the entry point is still kipui — but anything reaching past exports into dist/index.js will need updating.

publint and @arethetypeswrong/cli are clean on all four resolution modes. Package size is 98 KB packed.

1.1.0

Minor

Make the bundle tree-shakeable. Importing one component no longer pulls in the library.

2eafd8b

Measured in a bare Next 16 app importing nothing but Button, the whole package landed in the client bundle: 66.16 kB brotli against 73.43 kB for import *. One component cost 90% of everything, framer-motion included, even though Button has never used it.

Two module-level constructs were the cause, and neither is visible from the source:

  • X.displayName = "X" is a property assignment on a module-level binding. Bundlers must treat it as a side effect, so the component it names can never be dropped — and it holds every module it references in with it. The name now lives on the render function (forwardRef(function Badge(…))), which React DevTools reads the same way. Where the component was already a function X declaration the line was pure redundancy, because React falls back to fn.name.
  • forwardRef(…) and createContext(…) are calls of unknown purity at module level. Both now carry an explicit /* @__PURE__ */.

Button alone is now 9.31 kB brotli, down from 66.16 kB. import * is unchanged at 73 kB, as it should be — nothing was removed, it simply became possible to leave out. The annotation alone did nothing; the displayName assignments were the real anchor.

Sourcemaps are no longer built or published. The two of them were 972 KB of a 1.5 MB install — the package is now 555 KB unpacked, 122 KB packed — to debug a library whose source is public. Excluding them from files instead would have left a sourceMappingURL pointing at a file nobody ships.

Also fixed the exports map, which advertised the ESM .d.ts to require() consumers while serving them CJS — @arethetypeswrong/cli flagged it as masquerading as ESM. types now sits inside the import and require conditions, with ./dist/index.d.cts wired to the CJS build it was always generated for. publint and attw are clean on all four resolution modes.

Drop framer-motion from Badge and RadioGroup; their animations are now CSS.

b37ebb3

framer-motion is a real dependency, and because the package builds to one bundled file, importing any component pulls the whole runtime into the consumer's app. Badge was the worst case: a static label whose only motion was a mount fade, costing every consumer the entire animation library. RadioGroup was close behind — a scale and an opacity on a mark that is at most 24px across.

Both now animate with CSS, using tokens that already ship in theme.css:

  • Badge mounts with the animate-scale-in preset instead of motion.span. The class sits first in the list, so animate-none from the caller overrides it, and the duration scales with the --motion knob — neither of which the framer version did.
  • RadioGroup transitions the mark on ease-bounce, which carries the overshoot the spring had. The check appearance draws its tick with pathLength={1} and a stroke-dashoffset transition, the CSS equivalent of framer's pathLength.
  • Both now respect prefers-reduced-motion via motion-reduce:. The framer versions ignored it.

Two behaviour changes:

  • Badge no longer plays an exit animation. It never played one on its own — it required an <AnimatePresence> around the list in the consumer — but that path is gone. Removal is now immediate.
  • Badge no longer renders a wrapper <span>. The element carrying className, ref and the rest of the props is unchanged; there is simply one fewer element around it. Selectors written against that wrapper need updating.

Neither component imports framer-motion anymore, so once the package ships per-file output, an app that uses only these two will not download it at all.

Add --success-foreground, --warning-foreground and --info-foreground, and use them.

2750e5c

The three state fills had no text token, so nine components hardcoded text-white or text-black on top of them. Measured against the palettes that ship, white on --success is 3.3:1 and white on --info is 3.7:1 — both below the 4.5:1 WCAG AA threshold for body text, and neither fixable from a theme, because there was no variable to change. An app with a pale success colour had no way to make its own buttons legible.

All five palettes now declare the three tokens in both modes, and Alert, Badge, Button, Checkbox, RadioGroup, Switch, Toast and Tooltip read them. The shipped values measure 4.7:1 to 9.6:1.

Also removed from the same components: text-gray-700 dark:text-gray-200 in Field and Label (now text-foreground), text-red-500 and border-red-500 (now --destructive), the literal white/5 glass on the capsule Tabs variant (now tinted from --foreground, so it reads on a light theme), and the white/30 button ripple (now currentColor).

Two literal colours are kept deliberately and commented: the dialog scrim, which exists to darken what is behind it, and the tick colour when Checkbox is given an arbitrary color prop, where there is no token to pair with.

1.0.0

Major

kipui is now the component library itself, not a CLI that copies it.

b84bf72

Up to 0.1.0 this package was a tool: kipui add table fetched a registry document and wrote the component's source into your repository. That is gone — there is no kipui binary, no kipui add, no components.json and no registry to fetch from. Installing the package now gives you the components.

npm install kipui
/* app/globals.css */
@import "tailwindcss";
@import "kipui/theme.css";
@source "../node_modules/kipui/dist";
import { Button, Table, Dialog } from "kipui";

The @source line is not optional. Tailwind only generates a utility it has seen written down, and this package's class names live in node_modules, which it does not scan by default.

If you were using the CLI, nothing breaks on its own: the files it already wrote into your repository are yours and keep working. Upgrading to 1.0.0 gives you a package with no binary, so a kipui add in a script will fail — either stay on 0.1.0, or delete the copied files and import from the package instead.

All twenty-two components moved across unchanged apart from two things a package cannot assume about its consumer:

  • BreadcrumbLink no longer imports next/link. It renders a plain <a> by default and takes an as prop for your router's link component.
  • The font stacks read --kip-font-sans, --kip-font-mono and --kip-font-display instead of this repository's own --font-geist-* variables, each with a system fallback so an app that sets none still renders in a sans-serif rather than invalidating the declaration.

0.1.0

MinorPatch

First release of the CLI.

75fb7a4

kipui init writes a components.json describing where this project keeps its components and what its imports look like. kipui add <name> reads the registry, resolves everything the component imports — other components, shared helpers, hooks — and writes each file to the directory that config names, rewriting the @/ imports inside it to match. kipui list shows what is available and kipui diff compares your copy against the registry's, which is the question worth asking about code you were handed rather than installed.

Nothing is added to node_modules. The files are yours once they land, and the CLI has no opinion about what you do to them afterwards.

Corrected the documented API for several components.

31f9351

Tooltip's props table and usage snippet described an older single-component API — content, placement, delay, disabled — none of which the component accepts. Anyone following it wrote code that did not compile. It now documents the real Tooltip / TooltipTrigger / TooltipContent composition.

Thirty-eight props across Checkbox, RadioGroup, Switch, Select, Calendar, Table, Avatar, Dialog, Tabs and Pagination existed but were undocumented. They are now in their tables, and a test fails if any table drifts from the type again.

Point the default registry at the deployed site, and add a KIPUI_REGISTRY override.

3f1cffe

The default was https://kipui.dev/r, a domain nobody owns, so the CLI could not fetch anything. It now points at where the site actually is.

KIPUI_REGISTRY overrides both that default and whatever components.json says. The default is baked into each published version, so a reader on an old CLI would otherwise keep asking the old host forever — this is what makes the site movable without stranding them.

Tabs no longer requires defaultValue when value is supplied.

cdaacfb

It was typed as required, so a fully controlled Tabs had to pass an initial tab it would never read. The documentation site's own package-manager tabs were passing both, which is how the mistake stayed invisible — the workaround looked like ordinary usage.

© 2026 Kipui — MIT licensed

all systems nominal