Buttons, inputs, choice and checkbox rows in button appearance, navigation items: every fixed-height control in pindoba takes the same size prop (xs–xl), and a given size gives the same row height, padding and addon geometry whichever component you put it on. A md button next to a md input lines up, with no per-component magic numbers.
The scale lives in one object, SIZE_PRESETS (@pindoba/panda-preset). From it, the preset generates global CSS for each [data-control-size="<size>"] attribute, publishing the row’s geometry as CSS custom properties. A control’s connect function writes the data-control-size attribute on its root, and the recipe reads the variables with the controlRow pattern. Recipes never contain a table of sizes.
SIZE_PRESETS → [data-control-size="md"] { --control-h; --control-pad-x-base; … } → controlRow(): height, gap, padding-inline
Pick a size and watch the variables change. The dashed lines mark the shared row height, and the tinted bands show the inline padding of the measured control on each side.
--affix-box) dashed lines: shared row height ()| var on [data-control-size="md"] | computed | px |
|---|
This table is computed from SIZE_PRESETS and the spacing, radii and sizes tokens, so it can’t drift from the code. Values are in px at the system’s 10px root.
| size | height | inline padding | radius · addons |
|---|---|---|---|
| xs | 20px | pad xs NaNpx · affix 4xs NaNpx · gap 3xs NaNpx | radius sm NaNpx · affix box NaNpx · glyph 2xs / text 2xs |
| sm | 24px | pad sm NaNpx · affix 4xs NaNpx · gap 2xs NaNpx | radius sm NaNpx · affix box NaNpx · glyph xs / text xs |
| md | 32px | pad md NaNpx · affix 3xs NaNpx · gap xs NaNpx | radius md NaNpx · affix box NaNpx · glyph sm / text xs |
| lg | 40px | pad xl NaNpx · affix xs NaNpx · gap sm NaNpx | radius lg NaNpx · affix box NaNpx · glyph md / text sm |
| xl | 56px | pad 3xl NaNpx · affix sm NaNpx · gap md NaNpx | radius xl NaNpx · affix box NaNpx · glyph lg / text md |
Not every control has every step. Button covers xs–xl, but Input’s recipe stops at lg. An xl input still gets the xl variables but no fixed row height.
| Variable | What it is |
|---|---|
--control-h | Fixed row height (sizes.control.<size>). |
--control-gap | Flex gap between affixes and content. |
--control-pad-x-base | Resting inline padding. A side without an affix keeps this value. |
--control-pad-affix | Inline padding on a side that has an affix. |
--control-pad-start / --control-pad-end | What controlRow actually applies. These are outputs of the affix flip, so don’t redefine them. |
--control-radius | The row’s corner radius for this size. |
--control-border-w | The row’s reserved border width (1px). |
--affix-box | Square box for row-filling addons: control-h − 2·pad-affix − 2·border. |
--affix-radius | Concentric corner for addons: the row radius minus the affix inset. |
--affix-glyph-font / --affix-text-font | Font size for icon addons (Stamp) and text addons (Badge, Kbd). |
--affix-pad-x / --affix-pad-y | Inner padding for text addons. |
--affix-row-box / -glyph-font / -pad-x / -radius | The same values relayed through [data-affix] for an addon that is itself a control row. See nested controls. |
Icons, badges and shortcuts sit in an <Affix> child, with side="start" (the default) or side="end". The row doesn’t take props such as hasLeading. It detects affixes from its own structure, with :has(> [data-affix="start"]) and :has(> [data-affix="end"]), and adjusts the padding of each side separately:
--control-pad-affix, so the addon sits evenly inside the row edge;--control-pad-x-base, the resting padding.So a button with only a trailing chevron keeps its label exactly where a plain sibling’s label sits. Toggle the start and end affixes in the explorer above to see each band change on its own.
<script>
import Button from "@pindoba/svelte-button";
import Affix from "@pindoba/svelte-affix";
import Stamp from "@pindoba/svelte-stamp";
import Badge from "@pindoba/svelte-badge";
import Inbox from "@lucide/svelte/icons/inbox";
</script>
<Button size="md">
<Affix decorative>
<Stamp emphasis="ghost"><Inbox /></Stamp>
</Affix>
Inbox
<Affix side="end"><Badge>12</Badge></Affix>
</Button>
Put an icon in an affix inside a <Stamp>, never as a bare <svg>. The Stamp fills --affix-box and centres the glyph at --affix-glyph-font, so an icon sits the same way in a button, an input or a nav item, at every size. Use emphasis="ghost" for a plain icon (no fill, and it inherits currentColor). Use a filled stamp only when the accent is deliberate.
Other affix content isn’t wrapped: a text prefix like @, a <Badge>, a <Kbd> or an interactive <Button> either sizes itself to the row or owns its box. Use fit="content" on an affix to opt its subtree out of row sizing, and decorative to hide it from assistive technology.
Badge, Stamp and Kbd size themselves from the --affix-* variables whenever they’re inside a sized row (&:is([data-control-size] *)). Outside a row they use their own size prop. The preset never lists addon kinds, so adding a new kind of addon takes one rule in that addon’s own recipe:
// packages/styles/<addon>/src/<addon>.ts
root: {
"&:is([data-control-size] *):not([data-affix-fit='content'] *)": {
height: "var(--affix-box)",
fontSize: "var(--affix-text-font)",
borderRadius: "var(--affix-radius)",
},
},
Sometimes an addon is itself a control row, such as a clear or password-reveal <Button> in an input affix. That button sets its own data-control-size, which redefines --affix-* for its own subtree and hides the input’s values. To get around this, the preset relays the host row’s values through the affix element, which never sets the attribute itself:
[data-control-size] > [data-affix] {
--affix-row-box: var(--affix-box);
--affix-row-glyph-font: var(--affix-glyph-font);
--affix-row-pad-x: var(--affix-pad-x);
--affix-row-radius: var(--affix-radius);
}
Inside an affix, the button recipe reads --affix-row-*. It takes the same box as a Stamp next to it, the row’s glyph font and the concentric corner (circle and pill shapes keep their own rounding through --button-affix-radius). Its icon is snapped to an even pixel size so it centres on the pixel grid. Clicking the button keeps focus on the button rather than moving it to the field.
<InputRoot size="md">
<Affix decorative><Stamp emphasis="ghost"><Search /></Stamp></Affix>
<InputField placeholder="Search" />
<Affix side="end">
<Button emphasis="ghost" shape="circle" aria-label="Clear"><X /></Button>
</Affix>
</InputRoot>
To retune a component or a subtree, redefine the input variables: --control-pad-x-base, --control-gap, --control-pad-affix, --affix-*. Don’t win with a more specific selector. The input recipe does exactly this: it tightens the resting padding and sets the gap to the affix padding:
size: {
md: {
root: css.raw({
...sizing.raw({ size: "md", py: "none", inlinePad: false }),
...controlRow.raw({}),
"--control-pad-x-base": "var(--spacing-xs)",
"--control-gap": "var(--control-pad-affix)",
}),
},
},
Containers can do the same for everything inside them. For example, the combobox chip list pins --affix-box so chips fit its rows.
Never redefine --control-pad-start or --control-pad-end without a guard. A recipe-layer declaration always beats the base-layer affix flip, so the per-side padding stops working for that component.
size to controls that sit together. Shared height comes from the size, not from layout.<Affix> children (or a button’s leading/trailing snippets) and let the row detect them.<Stamp emphasis="ghost">.sizing.raw({ size, inlinePad: false }) and controlRow.raw({}), and set data-control-size in its connect function.hasLeading whose only job is to set CSS hooks.passThrough padding. Retune the input variables in its recipe.rhythm pattern on a control row. It adds a second source of height.