From e75662481496bbdeffbe5fa2ae3d28919bc08840 Mon Sep 17 00:00:00 2001
From: Ludvig Rask <74653458+ludvigrask@users.noreply.github.com>
Date: Wed, 4 Nov 2026 05:50:30 +0100
Subject: [PATCH] feat(ui): add the design system checkbox as the compact
appearance (#61516)
---
.../component-tests/input-surfaces.spec.ts | 28 +++
.../src/forms/checkbox/checkbox-compact.css | 169 ++++++++++++++++++
.../src/forms/checkbox/checkbox.stories.tsx | 35 +++-
packages/ui/src/forms/checkbox/checkbox.tsx | 124 +++++++++----
4 files changed, 323 insertions(+), 33 deletions(-)
create mode 100644 packages/ui/src/forms/checkbox/checkbox-compact.css
diff --git a/packages/app/component-tests/input-surfaces.spec.ts b/packages/app/component-tests/input-surfaces.spec.ts
index 12806180f69..fa2fde9473f 100644
--- a/packages/app/component-tests/input-surfaces.spec.ts
+++ b/packages/app/component-tests/input-surfaces.spec.ts
@@ -125,6 +125,34 @@ story("select fields come in the text input sizes and default to the inline trig
await expect(fields.nth(3)).toHaveCSS("box-shadow", "none")
})
+story("compact checkboxes use the design system control states and focus ring", async ({ mount, page }) => {
+ const root = await mount("ui-checkbox--compact")
+
+ await page.addStyleTag({
+ content: `[data-slot="checkbox-v2"] {
+ --v2-background-bg-base: rgb(20, 30, 40);
+ --v2-background-bg-layer-01: rgb(50, 60, 70);
+ --v2-background-bg-accent: rgb(1, 2, 3);
+ --v2-state-fg-danger: rgb(7, 8, 9);
+ }`,
+ })
+
+ const controls = root.locator('[data-slot="checkbox-v2-control"]')
+
+ await expect(controls.nth(0)).toHaveCSS("background-color", "rgb(20, 30, 40)")
+ await expect(controls.nth(0)).toHaveCSS("background-image", "none")
+ await expect(controls.nth(1)).toHaveCSS("background-color", "rgb(1, 2, 3)")
+ await expect(controls.nth(1)).not.toHaveCSS("background-image", "none")
+ await expect(controls.nth(2)).toHaveCSS("background-color", "rgb(1, 2, 3)")
+ await expect(controls.nth(3)).toHaveCSS("box-shadow", "rgb(7, 8, 9) 0px 0px 0px 0.5px inset")
+ await expect(controls.nth(6)).toHaveCSS("background-color", "rgb(50, 60, 70)")
+
+ await page.keyboard.press("Tab")
+ await expect(root.getByRole("checkbox", { name: "Default" })).toBeFocused()
+ await expect(controls.nth(0)).toHaveCSS("outline-width", "2px")
+ await expect(controls.nth(0)).toHaveCSS("outline-offset", "2px")
+})
+
story(
"text inputs keep clear and copy actions equally sized and inset across sizes and directions",
async ({ mount, page }) => {
diff --git a/packages/ui/src/forms/checkbox/checkbox-compact.css b/packages/ui/src/forms/checkbox/checkbox-compact.css
new file mode 100644
index 00000000000..42e948126c5
--- /dev/null
+++ b/packages/ui/src/forms/checkbox/checkbox-compact.css
@@ -0,0 +1,169 @@
+/* Vite can link this before the app entry CSS, so name every layer here or `components` ranks below Tailwind's base reset. */
+@layer properties, theme, base, components, utilities;
+
+@layer components {
+ [data-slot="checkbox-v2"] {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ cursor: default;
+
+ &:where([data-disabled]) {
+ cursor: not-allowed;
+ }
+
+ &:where([data-readonly]) {
+ pointer-events: none;
+ }
+ }
+
+ [data-slot="checkbox-v2-error"] {
+ color: var(--v2-state-fg-danger);
+ font-size: 12px;
+ font-weight: 440;
+ line-height: var(--line-height-compact);
+ }
+
+ [data-slot="checkbox-v2-error"]:empty {
+ display: none;
+ }
+
+ [data-slot="checkbox-v2-row"] {
+ position: relative;
+ display: flex;
+ flex-direction: row;
+ align-items: flex-start;
+ gap: 8px;
+ }
+
+ [data-slot="checkbox-v2-input"] {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border-width: 0;
+ }
+
+ /* 20px tall so the 16px control centres on the first 20px line of the label. */
+ [data-slot="checkbox-v2-control-stack"] {
+ position: relative;
+ width: 16px;
+ height: 20px;
+ flex: none;
+ }
+
+ [data-slot="checkbox-v2-control"] {
+ box-sizing: border-box;
+ position: absolute;
+ top: 2px;
+ left: 0;
+ width: 16px;
+ height: 16px;
+ border-radius: 4px;
+ color: var(--v2-icon-icon-contrast);
+ background: var(--v2-background-bg-base);
+ box-shadow: inset 0 0 0 0.5px var(--v2-border-border-strong);
+ transition:
+ background 85ms ease-out,
+ box-shadow 85ms ease-out;
+ }
+
+ [data-slot="checkbox-v2"][data-surface="layer-1"] [data-slot="checkbox-v2-control"] {
+ background: var(--v2-background-bg-layer-01);
+ }
+
+ [data-slot="checkbox-v2-indicator"] {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ }
+
+ [data-slot="checkbox-v2-text"] {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: flex-start;
+ gap: 6px;
+ min-width: 0;
+ }
+
+ [data-slot="checkbox-v2-label"] {
+ user-select: none;
+ color: var(--v2-text-text-base);
+ font-size: 13px;
+ font-weight: 440;
+ line-height: var(--line-height-base);
+ letter-spacing: -0.04px;
+ font-variation-settings: "slnt" 0;
+ }
+
+ [data-slot="checkbox-v2-description"] {
+ color: var(--v2-text-text-muted);
+ font-size: 11px;
+ font-weight: 440;
+ line-height: var(--line-height-tight);
+ letter-spacing: 0.05px;
+ user-select: none;
+ }
+
+ [data-slot="checkbox-v2"]:where(:not([data-readonly]))
+ [data-slot="checkbox-v2-input"]:focus-visible
+ ~ [data-slot="checkbox-v2-control-stack"]
+ [data-slot="checkbox-v2-control"] {
+ outline: 2px solid var(--v2-border-border-focus);
+ outline-offset: 2px;
+ }
+
+ @media (hover: hover) {
+ [data-slot="checkbox-v2"]:where(:hover):where(:not([data-disabled], [data-readonly], [data-invalid])):where(
+ :not([data-checked], [data-indeterminate])
+ )
+ [data-slot="checkbox-v2-control"] {
+ box-shadow:
+ inset 0 0 0 0.5px var(--v2-border-border-strong),
+ inset 0 0 0 100vmax var(--v2-overlay-simple-overlay-hover);
+ }
+
+ [data-slot="checkbox-v2"]:where([data-checked], [data-indeterminate]):where(:hover):where(
+ :not([data-disabled], [data-readonly])
+ )
+ [data-slot="checkbox-v2-control"] {
+ box-shadow:
+ inset 0 0 0 0.5px var(--v2-border-border-strong),
+ inset 0 0 0 100vmax var(--v2-overlay-simple-overlay-contrast-hover);
+ }
+ }
+
+ /* The depth gradient is Figma's white-to-clear fill at 20% opacity over the accent. */
+ [data-slot="checkbox-v2"]:where([data-checked], [data-indeterminate]) [data-slot="checkbox-v2-control"] {
+ background:
+ linear-gradient(180deg, var(--v2-alpha-light-20) 0%, var(--v2-alpha-light-0) 100%), var(--v2-background-bg-accent);
+ }
+
+ [data-slot="checkbox-v2"]:where([data-invalid]):where(:not([data-checked], [data-indeterminate]))
+ [data-slot="checkbox-v2-control"] {
+ background: var(--v2-state-bg-danger);
+ box-shadow: inset 0 0 0 0.5px var(--v2-state-fg-danger);
+ }
+
+ [data-slot="checkbox-v2"] .checkbox-v2-icon {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ display: none;
+ }
+
+ [data-slot="checkbox-v2"]:where([data-checked]):where(:not([data-indeterminate])) .checkbox-v2-icon--check,
+ [data-slot="checkbox-v2"]:where([data-indeterminate]) .checkbox-v2-icon--minus {
+ display: block;
+ }
+
+ [data-slot="checkbox-v2"]:where([data-disabled]) [data-slot="checkbox-v2-row"] {
+ opacity: 0.5;
+ }
+}
diff --git a/packages/ui/src/forms/checkbox/checkbox.stories.tsx b/packages/ui/src/forms/checkbox/checkbox.stories.tsx
index cf940f0f201..af619c08f8d 100644
--- a/packages/ui/src/forms/checkbox/checkbox.stories.tsx
+++ b/packages/ui/src/forms/checkbox/checkbox.stories.tsx
@@ -7,11 +7,14 @@ Checkbox control for multi-select or agreement inputs.
### API
- Uses Kobalte Checkbox props.
-- Optional: \`hideLabel\`, \`description\`, \`icon\`.
+- Optional: \`hideLabel\`, \`description\`, \`icon\` (standard only).
- Children render as the label.
+- \`appearance\`: \`standard\` (default, legacy) or \`compact\`, the design system checkbox.
+- \`surface\`: \`base\` (default) or \`layer-1\` for the compact control.
### Theming/tokens
-- Uses \`data-component="checkbox"\` and related slots.
+- Standard uses \`data-component="checkbox"\` and related slots.
+- Compact uses \`data-slot="checkbox-v2"\` with \`--v2-background-bg-accent\`, \`--v2-border-border-strong\` and \`--v2-state-fg-danger\`.
`
export default {
@@ -44,3 +47,31 @@ export const CustomIcon = {
}
export const HiddenLabel = { render: () =>