Skip to content
117 changes: 117 additions & 0 deletions libs/ui/docs/icon-button-unification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# Icon sub-button unification — research & plan

> Status: implemented & shipped (PR #467). Code complete; Figma `ActionIcon`
> component + frame migration is the remaining follow-up.
> Decisions locked: glyph scale **16 / 20 / 24**; hover **shared bg-pill**
> (light+dark via `--color-fill-hover`/`--color-fill-active`). Clear ✕ uses the
> **neutral** tone (subtle gray pill) — NOT red. `danger` tone is reserved for
> genuinely destructive actions. Hit area is a compact inset square (24/32/40),
> not full control height.

Comment thread
greptile-apps[bot] marked this conversation as resolved.
## Scope
Small icon-only interactive elements embedded in larger controls: dropdown
**chevrons**, **clear (✕)**, **increment/decrement**, **search**, **close (✕)**,
**prev/next**, expand **chevrons**, step **indicators**.

## Root cause — three competing sizing mechanisms
Icons render as a glyph font sized by `font-size` (`Icon` `size` → `text-icon-*`).
Three different mechanisms currently decide that size:

| # | Mechanism | How size is chosen | Behavior |
|---|-----------|--------------------|----------|
| A | Explicit keyword | JS ternary → `text-icon-{xs,sm,md}` | Fixed px, breakpoints differ per component |
| B | `current` / inherited | Icon inherits container `text-*` | Fluid (clamp), tracks viewport |
| C | Component CSS token | `text-<component>-icon-{sm,md,lg}` | Fixed, each component defines its own |

Active glyph scale (`tokens/figma/variables.css`): xs=12 · sm=14 · md=20 · lg=24 · xl=30 · 2xl=40 px.

## Current state (glyph px per component × size)

| Component | Sub-button | Mech | sm | md | lg | Hover |
|-----------|-----------|:--:|:--:|:--:|:--:|-------|
| NumericInput | inc / dec | A | 12 | 14 | 20 | bg-pill + fg |
| Combobox | chevron | A | 14 | 20 | 20 ⚠ | bg-pill + fg |
| Combobox | clear ✕ | B | 24 | 24 | 24 ⚠⚠ | bg-pill + fg |
| Select | chevron | A | 14 | 20 | 20 ⚠ | color only, no bg ⚠ |
| Select | clear ✕ | B | 14 | 20 | 24 ⚠ | bg-pill + danger |
| Select | (xs size) | A | chevron→20, no xs branch ⚠ | | | |
| SearchForm | clear ✕ | B | 14 | 20 | 24 | unstyled → no hover bg ⚠ |
| SearchForm | search icon | B | inherits button text | | | button hover |
| Accordion | chevron | B | inherits header text | | | rotate |
| Tree-view | node icon | C | 14 | 20 | 24 | scale-125 ⚠ |
| Tree-view | branch indicator | C | 20 | 24 | 30 | scale-125 |
| Breadcrumb | separator / ellipsis | C | 12 | 14 | 20 | n/a |
| Steps | indicator icon | C | 14 | 20 | 24 | in box |
| Tabs | trigger icon | B | 14 | 20 | 24 | bg-pill |
| Carousel | prev / next / autoplay | C | 20 | 20 | 20 ⚠ | bg-pill + fg |
| Pagination | prev / next / ellipsis | B | inherits button text | | | button hover |
| Dialog / Toast / Popover | close ✕ | B | inherits | | | color only |

## Problems
1. No agreement on glyph size for the same role (clear ✕ = 24/24/24 vs 14/20/24).
2. Mismatch within one component (Combobox md: chevron 20 vs clear 24).
3. `lg` doesn't grow (chevrons stay 20).
4. Fluid (B) vs fixed (A) mix → icons drift relative to each other across widths.
5. Select `xs` has no chevron branch → falls to 20.
6. Hover wildly inconsistent: bg-pill / color-only / scale-125 / none.
7. Hit-area/padding tokens per-component, no shared standard.

## Proposed direction
One shared icon-button standard with 3 sizes (mapped to form-control sm/md/lg),
covering glyph size + hit-area/padding + unified hover (light+dark). A small
nested **helper atom** owns this so it is defined **once** (Figma + code), with
per-component override still allowed.

### Token layer — `tokens/components/_icon-button.css` (temporary bridge)

These mirror the Figma `icon-control` collection and move into
`tokens/figma/variables.css` on the next Figma re-export (this file is deleted then):

```css
@theme static {
/* Glyph size (sm/md/lg) */
--text-icon-control-sm: var(--dimension-16); /* 16px */
--text-icon-control-md: var(--text-icon-md); /* 20px */
--text-icon-control-lg: var(--text-icon-lg); /* 24px */

/* Hit area — compact inset square (8-pt grid 24/32/40) */
--size-icon-control-sm: var(--dimension-24);
--size-icon-control-md: var(--dimension-32);
--size-icon-control-lg: var(--dimension-40);
--radius-icon-control: var(--radius-button-sm);

/* Neutral tone — full-contrast glyph; bg pill is the hover/active affordance */
--color-icon-control-fg: var(--color-fg-primary);
--color-icon-control-bg-hover: var(--color-fill-hover);
--color-icon-control-bg-active: var(--color-fill-active);
--color-icon-control-fg-disabled: var(--color-fg-disabled);
--color-icon-control-ring: var(--color-ring);

/* Danger tone — destructive actions only */
--color-icon-control-fg-danger-hover: var(--color-danger-fg);
--color-icon-control-bg-danger-hover: var(--color-danger-light);
--color-icon-control-bg-danger-active: var(--color-danger-light-active);
}
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

The interactive button (hit-area, glyph, hover/active pill) lives in the
`ActionIcon` atom (`src/atoms/action-icon.tsx`); chevrons read the
`--text-icon-control-*` glyph tokens directly.

### Migration (per component)
NumericInput, Combobox, Select, SearchForm, Accordion, Dialog, Toast, Popover,
Tabs, Pagination, Carousel, Tree-view, Breadcrumb, Steps, Phone-input — replace
bespoke ternaries / `current` / per-component CSS with the shared helper.
`xs` form sizes map to `sm` (16).

Out of scope: radio-card mark, checkbox/switch/slider marks, status-text icons.

### Validation
`pnpm validate:tokens`; `bunx biome check --write <files>`; `bunx nx run ui-kit:build`;
Storybook visual pass (sm/md/lg, light + dark) via browser agent.

### Figma
Mirror the helper as a nested component with a `size` variant; bind component
tokens `text/icon-control/{sm,md,lg}`, `color/icon-control/bg/hover[/danger]`
through semantic→core; explicit scopes + CSS code syntax. Validate every frame
by screenshot so the existing Figma file stays unbroken.
80 changes: 80 additions & 0 deletions libs/ui/src/atoms/action-icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import type { ButtonHTMLAttributes, Ref } from "react"
import type { VariantProps } from "tailwind-variants"
import { tv } from "../utils"
import { Icon, type IconType } from "./icon"

/*
* ActionIcon — the single icon-only "sub-button" used inside larger controls
* (clear ✕, increment/decrement, close ✕, prev/next, search). Glyph size,
* hit area, radius and the hover/active pill all come from the shared
* `--*-icon-control-*` tokens in tokens/components/_icon-button.css, so every
* icon button across the system stays consistent across sm/md/lg and light/dark.
*
* Decorative chevrons are NOT this component — they live inside a trigger
* <button> and read the `--text-icon-control-*` glyph tokens directly.
*/
const actionIconVariants = tv({
base: [
"inline-flex shrink-0 cursor-pointer items-center justify-center",
"rounded-icon-control text-icon-control-fg",
"transition-colors duration-200 motion-reduce:transition-none",
"focus-visible:outline-(style:--default-ring-style) focus-visible:outline-(length:--default-ring-width)",
"focus-visible:outline-offset-(length:--default-ring-offset) focus-visible:outline-icon-control-ring",
"disabled:cursor-not-allowed disabled:text-icon-control-fg-disabled",
],
variants: {
size: {
sm: "size-icon-control-sm text-icon-control-sm",
md: "size-icon-control-md text-icon-control-md",
lg: "size-icon-control-lg text-icon-control-lg",
},
tone: {
neutral: [
"hover:bg-icon-control-bg-hover",
"active:bg-icon-control-bg-active",
],
danger: [
"hover:bg-icon-control-bg-danger-hover hover:text-icon-control-fg-danger-hover",
"active:bg-icon-control-bg-danger-active",
],
Comment thread
greptile-apps[bot] marked this conversation as resolved.
},
},
defaultVariants: {
size: "md",
tone: "neutral",
},
})

type ActionIconVariants = VariantProps<typeof actionIconVariants>

export type ActionIconProps = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
"children"
> &
ActionIconVariants & {
icon: IconType
ref?: Ref<HTMLButtonElement>
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export function ActionIcon({
icon,
size,
tone,
type = "button",
className,
ref,
...props
}: ActionIconProps) {
return (
<button
className={actionIconVariants({ size, tone, className })}
ref={ref}
type={type}
{...props}
>
<Icon icon={icon} size="current" />
</button>
)
}

ActionIcon.displayName = "ActionIcon"
14 changes: 10 additions & 4 deletions libs/ui/src/atoms/numeric-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,17 +46,23 @@ const numericInputVariants = tv({
"focus-visible:outline-none",
"duration-0 data-invalid:focus:border-input-border-danger-focus",
],
// Subtle divider from the input instead of a gray fill block; the gap-px
// shows the field behind it as a hairline between the two arrows.
triggerContainer: [
"flex flex-col gap-px self-stretch bg-numeric-input-trigger-container-bg",
"flex flex-col gap-px self-stretch",
"border-numeric-input-border border-s",
],
// Unified neutral icon-control treatment: transparent base (matches the
// field, no "disabled" gray), neutral arrows, subtle neutral hover pill —
// no blue arrow-on-gray. Glyph size is kept per NumericInput's own scale.
trigger: [
"flex flex-1 place-items-center",
"px-numeric-input-trigger-x py-numeric-input-trigger-y",
"bg-numeric-input-trigger-bg hover:bg-numeric-input-trigger-bg-hover",
"text-numeric-input-trigger-fg hover:text-numeric-input-trigger-fg-hover",
"bg-transparent hover:bg-icon-control-bg-hover active:bg-icon-control-bg-active",
"text-icon-control-fg",
"cursor-pointer",
"transition-colors duration-200 motion-reduce:transition-none",
"disabled:cursor-not-allowed",
"disabled:cursor-not-allowed disabled:text-icon-control-fg-disabled",
],
scrubber: "absolute inset-0 cursor-ew-resize",
},
Expand Down
3 changes: 3 additions & 0 deletions libs/ui/src/molecules/accordion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,16 +60,19 @@ const accordionVariants = tv({
title: "p-accordion-title-sm text-accordion-title-sm",
content: "px-accordion-content-x-sm text-accordion-content-sm",
subtitle: "text-accordion-subtitle-sm",
icon: "text-icon-control-sm",
},
md: {
title: "p-accordion-title-md text-accordion-title-md",
content: "p-accordion-content-md text-accordion-content-md",
subtitle: "text-accordion-subtitle-md",
icon: "text-icon-control-md",
},
lg: {
title: "p-accordion-title-lg text-accordion-title-lg",
content: "p-accordion-content-lg text-accordion-content-lg",
subtitle: "text-accordion-subtitle-lg",
icon: "text-icon-control-lg",
},
},
},
Expand Down
33 changes: 14 additions & 19 deletions libs/ui/src/molecules/combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
import { normalizeProps, Portal, useMachine } from "@zag-js/react"
import { useEffect, useId, useState } from "react"
import type { VariantProps } from "tailwind-variants"
import { ActionIcon } from "../atoms/action-icon"
import { Button } from "../atoms/button"
import { Icon, type IconProps, type IconType } from "../atoms/icon"
import { Input } from "../atoms/input"
Expand Down Expand Up @@ -35,16 +36,14 @@ const comboboxVariants = tv({
"data-[validation=warning]:border-combobox-border-warning",
],
input: [
"relative h-full w-full border-none bg-combobox-input-bg-base",
"relative h-full min-w-0 flex-1 border-none bg-combobox-input-bg-base",
"hover:bg-combobox-input-bg-hover focus-visible:outline-none",
"focus:bg-combobox-input-bg-focus",
"placeholder:text-combobox-fg-placeholder",
"data-disabled:text-combobox-fg-disabled",
"data-disabled:bg-combobox-bg-disabled",
],
clearTrigger: [
"absolute right-combobox-clear-right h-full p-combobox-trigger",
],
// Trailing actions (clear + chevron) sit side by side with NO gap.
trigger: [
"group flex h-full shrink-0 items-center justify-center",
"font-normal",
Expand Down Expand Up @@ -88,12 +87,12 @@ const comboboxVariants = tv({
},
compoundSlots: [
{
slots: ["clearTrigger", "trigger"],
slots: ["trigger"],
class: [
"focus-visible:outline-(style:--default-ring-style) focus-visible:outline-(length:--default-ring-width)",
"focus-visible:outline-combobox-ring",
"focus-visible:outline-offset-(length:--default-ring-offset)",
"text-combobox-trigger-fg-base text-combobox-trigger",
"text-combobox-trigger text-combobox-trigger-fg-base",
"hover:text-combobox-trigger-fg-hover",
"motion-safe:transition-colors motion-safe:duration-200 motion-reduce:transition-none",
"hover:bg-combobox-trigger-bg-hover",
Expand All @@ -110,6 +109,7 @@ const comboboxVariants = tv({
emptyState: "p-combobox-item-sm text-combobox-item-sm",
input: "p-combobox-input-sm",
content: "text-combobox-sm",
triggerIndicator: "text-icon-control-sm",
},
md: {
root: "gap-combobox-md",
Expand All @@ -118,6 +118,7 @@ const comboboxVariants = tv({
emptyState: "p-combobox-item-md text-combobox-item-md",
input: "p-combobox-input-md",
content: "text-combobox-md",
triggerIndicator: "text-icon-control-md",
},
lg: {
root: "gap-combobox-lg",
Expand All @@ -126,6 +127,7 @@ const comboboxVariants = tv({
emptyState: "p-combobox-item-lg text-combobox-item-lg",
input: "p-combobox-input-lg",
content: "text-combobox-lg",
triggerIndicator: "text-icon-control-lg",
},
},
},
Expand Down Expand Up @@ -169,7 +171,6 @@ export interface ComboboxProps<T = unknown>
triggerIcon?: IconType
triggerIconSize?: IconProps["size"]
clearIcon?: IconType
clearIconSize?: IconProps["size"]
onChange?: (value: string | string[]) => void
onInputValueChange?: (value: string) => void
onOpenChange?: (open: boolean) => void
Expand Down Expand Up @@ -203,14 +204,11 @@ export function Combobox<T = unknown>({
triggerIcon = "token-icon-combobox-chevron",
triggerIconSize,
clearIcon = "token-icon-combobox-clear",
clearIconSize,
inputBehavior = "autocomplete",
onChange,
onInputValueChange,
onOpenChange,
}: ComboboxProps<T>) {
const resolvedChevronIconSize = size === "sm" ? "sm" : "md"

const generatedId = useId()
const uniqueId = id || generatedId

Expand Down Expand Up @@ -276,7 +274,6 @@ export function Combobox<T = unknown>({
positioner,
content,
list,
clearTrigger,
item: itemSlot,
emptyState,
triggerIndicator,
Expand Down Expand Up @@ -312,14 +309,12 @@ export function Combobox<T = unknown>({
/>

{clearable && api.value.length > 0 && (
<Button
className={clearTrigger()}
size="current"
theme="unstyled"
<ActionIcon
icon={clearIcon}
size={size ?? "md"}
tone="neutral"
{...api.getClearTriggerProps()}
>
<Icon icon={clearIcon} size={clearIconSize ?? "current"} />
</Button>
/>
)}

<Button
Expand All @@ -331,7 +326,7 @@ export function Combobox<T = unknown>({
<Icon
className={triggerIndicator()}
icon={triggerIcon}
size={triggerIconSize ?? resolvedChevronIconSize}
size={triggerIconSize ?? "current"}
/>
</Button>
</div>
Expand Down
Loading
Loading