Contributing
KinetixUI is one design source, one implementation per platform. React
(@kinetixui/ui) is the source of truth; Jetpack Compose
(packages/ui-compose), SwiftUI (packages/ui-swiftui) and Flutter
(packages/ui-flutter) each carry a 1:1 port of the same component
surface on the same token contract. Angular (packages/ui-angular) is a
fifth implementation, still in preview and rolling out in waves — see
Angular.
The platform rule#
A new component or block is not "done" until it exists on all four catalogue-complete platforms — React, SwiftUI, Jetpack Compose and Flutter — or is a documented, deliberate non-port on the ones where it has no native idiom. Each port:
- Mirrors the React source 1:1 — the same variants, sizes, states
and prop names, adjusted only for platform idiom (
onPressed/onChangedcallbacks instead of DOM events,value+ValueChangedinstead of controlled inputs, etc.). - Resolves the same semantic tokens — never a hard-coded colour.
Every port reads its
KinetixTheme/KinetixColors/@Environment(\.kinetixColors), which come from the shared token pipeline. - Carries a doc comment at the top of the file naming its
packages/ui/src/components/*.tsxcounterpart and stating exactly what wasn't carried over and why. - Compiles in CI — see the per-platform workflow below. A port that only exists locally doesn't count.
Every platform tab must have an answer#
A component page shows a tab for all five platforms, and a tab is never left
blank or filled with something invented. Where a component is not implemented
on a platform, components.manifest.json carries a platformGuidance entry
saying what the tab shows instead:
| Kind | What the tab shows | Counts as parity? |
|---|---|---|
| (implemented) | the component's own example | Yes — this is what platforms means |
native-equivalent | the platform's own idiom, compiled | No |
composition | other KinetixUI components, composed | No |
planned | nothing — a statement and the delivery wave | No |
pnpm check:manifest fails if any (component, platform) pair has neither an
implementation nor guidance, if a guidance entry names a platform the component
is actually on, or if a non-planned entry has no reason.
pnpm check:platform-code fails in the other direction: guidance that promises
a snippet and has none, and a snippet sitting under a planned gap.
Guidance is deliberately never counted as coverage. The per-platform numbers on this site mean one thing — a real implementation — and that is the only reason they are worth printing.
The standing non-ports#
These components are not ported to any native platform, by design. Each one
carries composition or native-equivalent guidance per platform in
components.manifest.json, so this table cannot drift from what actually
ships:
| Component | Why | Use instead |
|---|---|---|
Form | react-hook-form glue with no native analogue | KinetixField (+ Label / Description / Message) |
NavigationMenu | hover-triggered desktop mega-menu, no touch idiom | platform navigation (NavigationSplitView, NavigationRail, …) |
Combobox | a recipe (Popover + Command), not a standalone component | KinetixPopover + a filtered list, or KinetixSelect |
NativeSelect | wraps the browser's own <select> — a web-only escape hatch from Select's custom popover | KinetixSelect, which already wraps each native platform's own picker mechanism (Material3 DropdownMenu, SwiftUI Menu, Flutter MenuAnchor) |
AvatarGroup | re-wraps its children (React.Children.toArray) — not an idiomatic pattern on any native layout system | a Row/HStack/Row of KinetixAvatars with negative spacing (spacedBy((-8).dp) / spacing: -8 / a Transform-shifted Stack) |
DirectionProvider | threads text direction through Radix on the web; SwiftUI (environment(\.layoutDirection)), Compose (LocalLayoutDirection) and Flutter (Directionality) already carry layout direction in the framework itself | the framework's own directionality value — there is nothing to wrap |
Tour | targets an arbitrary already-rendered element by CSS selector — no native platform has an equivalent live-tree query, only opt-in position reporting (Modifier.onGloballyPositioned + a shared registry, a PreferenceKey, a GlobalKey) | a sequence of KinetixPopover/KinetixDropdownMenu steps, each anchored to the element it explains |
KanbanBoard | built on @dnd-kit's accessible multi-container drag-and-drop — no equivalent dependency exists in this repo's native packages, and hand-rolling pointer + touch + keyboard DnD with collision detection and live reordering from scratch on three more platforms is a much bigger lift than porting the component's own logic | each platform's own idiomatic drag primitive (Compose drag gestures + LazyColumn, SwiftUI .draggable/.dropDestination, Flutter Draggable/DragTarget), composed by hand for the specific board |
Anything else that can't be ported cleanly is scoped down and
documented, not silently skipped — e.g. KinetixSheet is bottom-only,
KinetixSidebar drops rail mode, KinetixChart is bar-only on Flutter.
Component status#
KinetixUI tracks three different things, and they are not the same. Printing one
where a reader would take it for another is how stable came to mean nothing:
| Question it answers | Where it lives | |
|---|---|---|
| Component lifecycle | Is this component's API settled? | status in components.manifest.json |
| Package maturity | Is this platform's package a finished product? | platformDefinitions[].maturity |
| Implementation verification | What do automated tests prove about this implementation? | verification.json, derived from the tests |
None is derived from another. @kinetixui/ui is a stable, published package
whose catalogue's verification is beta, containing components whose
lifecycle is stable — three true statements that one word cannot make. See
Supported platforms for the other two; this section is about
lifecycle only.
What lifecycle Beta means#
The component ships and is CI-compiled like everything else, but something about
its product or API is unresolved — and that reason is recorded in the
manifest as lifecycleReason. pnpm check:manifest fails on a beta component
without one, and on a stable component that still carries one.
Beta is not a waiting room. It used to be: the rule was "beta for the release cycle it lands in, stable once it's shipped a cycle with no reported issues," which meant twelve components introduced in 0.12.0 were still beta at 0.22.1. That made the label a record of an unrun chore rather than a statement about the API. Time in beta is a reason to review a component; it is never the reason to promote one.
What lifecycle Stable means#
A component graduates when all of these hold:
- its purpose is clear and its anatomy is settled;
- its public API is coherent and follows the shared conventions —
value/defaultValue/onValueChange,disabled,size,variant; - it works controlled and uncontrolled where it carries a value;
- the API is unlikely to need a redesign;
- keyboard behaviour is defined where applicable;
- the accessibility contract is intentional — roles, names, states, focus;
- RTL behaviour is understood where relevant;
- empty, error, loading and disabled states are defined where relevant;
- the docs let someone use it without reading the source;
- no known P0/P1 product or API defect remains;
- the cross-platform adaptation strategy is understood and documented honestly.
Lifecycle Stable deliberately does not require verification Stable, visual
regression on every platform, published native packages, or an Angular
implementation. A component can be lifecycle stable while its SwiftUI port is
verified only to experimental: one statement is about the API, the other about
evidence.
Breaking changes before 1.0#
While a component is Beta, a bad API is corrected rather than preserved — directly, with a Changeset explaining why the old shape was unsuitable. That is what Beta is for. Carrying a compatibility alias to 1.0 to avoid a rename costs more than the rename. Once a component is Stable, a breaking change needs a deprecation path.
deprecated is reserved for a component being phased out; nothing has been
deprecated yet.
Status and platform coverage live in one place, components.manifest.json at
the repo root. pnpm gen:manifest derives component-status.json and
platform-parity.json from it (the web app, the CLI registry and kinetixui parity read those), and pnpm check:manifest in CI fails if a component is
missing, a status is invalid, a platform gap has no platformNote or no
platformGuidance, or the generated files are stale. The data drives the /components gallery
badges/filter and the <ComponentMeta> spec strip on each doc page.
Promotion is a judgement against the gate above, made when the blocker in a
component's lifecycleReason is actually resolved — not a release-day chore.
pnpm report:beta lists every beta component with its lifecycle blocker and,
separately, its verification gaps.
Workflow for a new component#
- Design tokens first. If the component needs a token that doesn't
exist, add it to
tokens/(DTCG), runpnpm build:tokens, and commit the regeneratedpackages/tokens/dist/**. The CI "generated files in sync" gate fails otherwise. If a native library needs the new semantic colour, it flows through automatically — theandroid-compose-theme/ios-swiftui-theme/flutter-color-schemeblocks run on both light and dark passes. - React — build it in
packages/ui/src/components/<name>.tsx, add a Storybook story and a demo.pnpm build:registrypicks it up for the CLI automatically. Add the component tocomponents.manifest.json(statusbeta,since= the version it will ship in, itsplatforms) and runpnpm gen:manifest— CI fails without it. - Jetpack Compose —
packages/ui-compose/ui/src/main/kotlin/com/kinetixui/ui/<Name>.kt. Vendored tokens:pnpm build:tokens && pnpm vendor:compose. - SwiftUI —
packages/ui-swiftui/Sources/KinetixUI/<Name>.swift. Vendored tokens:pnpm build:tokens && pnpm vendor:swiftui. - Flutter —
packages/ui-flutter/lib/src/<name>.dart, plus anexportline inlib/kinetix_ui.dart. Vendored tokens:pnpm build:tokens && pnpm vendor:flutter. - Docs — add the component to
/docs/compose,/docs/swiftuiand/docs/flutter's "what's covered" lists and to/docs/changelog.
CI — the compiler feedback loop#
None of the native toolchains are assumed to be on a contributor's
machine. Each platform has a path-filtered workflow that is the sole
compile check for that library — additive, never touching ci.yml /
release.yml:
| Platform | Workflow | Runner | Check |
|---|---|---|---|
| React / tokens | ci.yml | ubuntu | vitest, build:registry, generated-files-in-sync, build:web |
| Jetpack Compose | native-compose.yml | ubuntu | gradle :ui:assembleDebug + :ui:lintDebug |
| SwiftUI | native-swiftui.yml | macos | swift build (.macOS(.v13) target, no simulator) |
| Flutter | native-flutter.yml | ubuntu | flutter pub get, flutter analyze, flutter test (widget smoke suite) |
All four are path-filtered to the platform's package plus tokens/**
and style-dictionary/**, so a token change re-checks every native
library.
Cutting a release (the changelog page)#
Changesets opens a "version packages" PR that bumps the core npm packages — @kinetixui/tokens,
@kinetixui/ui and @kinetixui/cli — together. (@kinetixui/angular is its own release cohort and
versions separately; a release of one does not move the other.) That PR cannot
merge until /docs/changelog has an entry for the new version — pnpm check:releases (in ci.yml)
fails when the top entry in apps/web/src/lib/releases.ts isn't the version the packages are at. This
exists because the page once sat at 0.6.0 for eleven releases while the packages went to 0.17.0.
- On the release PR's branch, add an entry at the top of
RELEASES:version,date, a one-linesummary, the typedchanges(new/improved/fixed/security/accessibility/breaking, each with anarea), andlimitationsfor anything the release deliberately doesn't do. - Set
breakingexplicitly —[]means "none, and I checked"; the check fails if it's missing. Add amigrationnote when a change needs action from users. - List new components under
newComponents; the page adds platform badges fromplatform-parity.json, so don't write coverage by hand. - The per-package "changed / unchanged" indicators are generated from the package changelogs
(
node scripts/gen-release-meta.mjs) — run it if the check says they're stale.
Not every version has a GitHub Release page: versions between v0.5.0 and the release-automation change
carry only a per-package tag, so the page links the tag unless githubReleaseUrl is set. Releases created
from now on are made by scripts/release/github-releases.mjs during the release itself. If you publish one
by hand, generate its notes from the same curated entry — nothing is written twice:
pnpm release:notes 0.21.0 # prints Markdown to paste into the GitHub Release
pnpm release:notes 0.21.0 --out notes.mdThen add githubReleaseUrl to that entry (never guess it — check:releases requires it to be a
github.com/zedalleys/kinetixui/releases/tag/… link naming the same version) and the changelog shows
"View release on GitHub" instead of "Tag on GitHub". The script only reads releases.ts and the generated
package metadata; it never talks to GitHub.
The changelog page itself is searchable and its filter and search live in the URL, so any view can be
shared: /docs/changelog?filter=accessibility, ?q=DataGrid, ?filter=components&q=grid.
Publishing the native ports#
React (@kinetixui/tokens / @kinetixui/ui / @kinetixui/cli) publishes
to npm via Changesets on merge to main. The three native ports are
consumed from a repo checkout today; the manual release paths are
workflow_dispatch workflows, credential-guarded so they're inert until
you add the secret:
| Port | Workflow | Mechanism | Secret |
|---|---|---|---|
| Flutter | publish-flutter.yml | flutter pub publish to pub.dev (dry-run by default) | PUB_DEV_CREDENTIALS — the contents of ~/.config/dart/pub-credentials.json after dart pub login |
| SwiftUI | publish-swiftui.yml | git subtree split of packages/ui-swiftui → a thin mirror repo (SwiftPM can't resolve a package in a monorepo subdirectory) | SWIFTUI_MIRROR_REPO — an authenticated push URL, https://x-access-token:<PAT>@github.com/<you>/<mirror>.git |
| Jetpack Compose | publish-compose.yml | gradle publish to Maven Central via the Central Portal's OSSRH-compatible staging API (dry-run = unsigned publishToMavenLocal); the staged deployment is then released by hand at central.sonatype.com | MAVEN_CENTRAL_USERNAME / MAVEN_CENTRAL_PASSWORD (a Portal user token) and SIGNING_KEY / SIGNING_KEY_PASSWORD (an ASCII-armored GPG key — gpg --armor --export-secret-keys) |
Blocks#
Blocks (the composed examples on /blocks) go further than the
component rule: a published block must exist on all five platforms —
React, Angular, SwiftUI, Jetpack Compose and Flutter. A block is a
composition of Kinetix* primitives, so a block that works on the web is
expressible from each platform's own primitives; and a reader who copies
the React tab has no way to tell that the Compose tab was never written.
A block on two platforms is not a smaller version of the idea, it is a
different claim.
pnpm check:block-source enforces this: a block with status beta,
stable or deprecated must list every platform. The one escape hatch
is status draft, which may be missing platforms and which the
website never publishes — gen-block-examples.mjs leaves drafts out of
the generated snippets entirely, so work in progress can live in the
manifest without the catalogue advertising a hole.
Block code is not written into the website. Every snippet the site shows is extracted from a real source file, and a block may only advertise a platform it has one for.
Composition, not translation. Where a platform has a better idiom, the
block uses it and the file says why: the table toolbar is a list on
SwiftUI because three columns on a phone stop being readable at larger
text sizes, and the settings list is a semantic <ul> on Angular because
the list component is in a later wave and inventing a kx-list is the
one thing this architecture forbids.
Adding a block implementation#
-
Write the real source. One file, in that platform's home:
Platform Where React apps/web/src/examples/blocks/<slug>.tsxAngular packages/ui-angular/src/examples/blocks/<slug>.tsSwiftUI packages/ui-swiftui/Tests/KinetixUITests/Blocks/<Name>BlockTests.swiftCompose packages/ui-compose/ui/src/test/kotlin/com/kinetixui/ui/blocks/<Name>BlockTest.ktFlutter packages/ui-flutter/test/blocks/<name>_block_test.dartWrap the part a reader should copy in
// kx-block:start/// kx-block:end. Imports and any test around it stay outside the markers — one file, so the example and the thing the compiler checks can never be two different pieces of code. -
Declare it in
blocks.manifest.jsonunder that block'ssources, with the explicit path. Platform names are validated againstcomponents.manifest.json'splatformDefinitions; there is no second platform list. -
Regenerate:
pnpm gen:blocks. -
Check:
pnpm check:block-source(every claim has a file) andpnpm check:blocks(the generated snippets match the files). -
CI runs both, and the native workflows compile the native sources — the file paths above sit inside
packages/ui-*, which is what those workflows watch.
A React block is also its own live preview: blockPreviews imports the
fixture, so the code on the page and the thing rendered above it are the
same component.
Coverage is explicit: a platform missing from a block's sources is not
supported for that block, and nothing infers otherwise. For a published
block that is a failure rather than a fact — see the five-platform rule
above. block-parity.json carries the derived counts, and the /blocks
page reads them rather than printing a number anyone typed.