Spacing
Shared spacing steps define container insets, related groups and the separation between sections.
On this page
Panel inset
Between cards
Vessel details
Use the name on the vessel's documents.
Before departure
Review the route and prepare lines and fenders.
Code
import { Box } from "@bosunkit/components/box";
import { Button } from "@bosunkit/components/button";
import { Card } from "@bosunkit/components/card";
import { FormField } from "@bosunkit/components/form-field";
import { Input } from "@bosunkit/components/input";
import { SegmentedRadioGroup } from "@bosunkit/components/segmented-radio-group";
import { Select } from "@bosunkit/components/select";
import { Stack, type StackProps } from "@bosunkit/components/stack";
import { Text } from "@bosunkit/components/text";
import { PresentationScope } from "@bosunkit/provider";
import tokens from "@bosunkit/tokens/tokens.json";
import type { SizeClass } from "@bosunkit/tokens/theme";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"Change the panel inset and the gap between real cards. Guides reveal those spaces; each card owns its internal layout.";
type Space = NonNullable<StackProps["space"]>;
const STEPS: readonly Space[] = ["xs", "sm", "md", "lg", "xl", "2xl"];
function SpacePicker({
label,
value,
onChange,
}: {
label: string;
value: Space;
onChange: (space: Space) => void;
}): ReactElement {
return (
<Stack space="xs">
<Text variant="label">{label}</Text>
<Select.Root
value={value}
onValueChange={(next: string | null): void => { ((next: string): void => {
const step: Space | undefined = STEPS.find((candidate: Space): boolean => candidate === next);
if (step !== undefined) onChange(step);
})(next ?? ""); }}
>
<Select.Trigger aria-label={label}>
<Select.Value />
</Select.Trigger>
<Select.Portal><Select.Positioner alignItemWithTrigger={true} ><Select.Popup ><Select.List>
{STEPS.map((step: Space): ReactElement => (
<Select.Item key={step} value={step}>
{step} · {tokens.base[`space-${step}`]}
</Select.Item>
))}
</Select.List></Select.Popup></Select.Positioner></Select.Portal>
</Select.Root>
</Stack>
);
}
export default function Example({ compact = false }: { compact?: boolean }): ReactElement {
const [inset, setInset]: [Space, Dispatch<SetStateAction<Space>>] = useState<Space>("lg");
const [gap, setGap]: [Space, Dispatch<SetStateAction<Space>>] = useState<Space>("xl");
const [size, setSize]: [SizeClass, Dispatch<SetStateAction<SizeClass>>] = useState<SizeClass>("default");
const [guides, setGuides]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(true);
function reset(): void {
setInset("lg");
setGap("xl");
setSize("default");
setGuides(true);
}
return (
<div role="group" aria-label={compact ? "Spacing overview" : "Spacing playground"} style={{ width: "100%" }}>
<Stack space="lg">
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: "var(--space-md)" }}>
<SpacePicker label="Panel inset" value={inset} onChange={setInset} />
<SpacePicker label="Between cards" value={gap} onChange={setGap} />
</div>
{!compact && <SegmentedRadioGroup
label="Presentation size"
value={size}
motion={false}
options={[
{ value: "default", label: "Default" },
{ value: "helm", label: "Helm" },
]}
onValueChange={(value: string): void => {
setSize(value === "helm" ? "helm" : "default");
}}
/>}
{!compact && <div style={{ display: "flex", flexWrap: "wrap", gap: "var(--space-sm)" }}>
<Button
textCase="none"
motion={false}
aria-pressed={guides}
onClick={(): void => {
setGuides(!guides);
}}
>
Show spacing guides
</Button>
<Button textCase="none" motion={false} onClick={reset}>
Reset spacing
</Button>
</div>}
<PresentationScope sizeClass={size}>
<Box background={guides ? "accent" : "surface2"} padding={inset} radius="md">
<Stack space={gap}>
<Card title="Vessel details" headingLevel={2}>
<Stack space="lg">
<FormField label="Vessel name" hint="Use the name on the vessel's documents.">
<Input defaultValue="Windgrazer" />
</FormField>
{!compact && <FormField label="Home port">
<Input defaultValue="Brest" />
</FormField>}
</Stack>
</Card>
<Card title="Before departure" headingLevel={2}>
<Text>Review the route and prepare lines and fenders.</Text>
</Card>
</Stack>
</Box>
</PresentationScope>
{!compact && <Text variant="caption" color="text-dim">
Accent guides show the outer inset and the space between cards. Changing text size keeps these spacing steps.
</Text>}
{!compact && <pre style={{ margin: 0, whiteSpace: "pre-wrap", font: "var(--bk-type-code)" }}>
{`<Box padding="${inset}">\n <Stack space="${gap}">\n <Card title="Vessel details">…</Card>\n <Card title="Before departure">…</Card>\n </Stack>\n</Box>`}
</pre>}
</Stack>
</div>
);
}
Scale
The six steps come from the kit token stylesheet. The table reads the generated kit values; use the token name in CSS or its short name in a layout primitive. Zero means no additional space and needs no token.
| Token | Value | rem | Preview |
|---|---|---|---|
--space-xs | 4px | 0.25rem | |
--space-sm | 8px | 0.5rem | |
--space-md | 12px | 0.75rem | |
--space-lg | 16px | 1rem | |
--space-xl | 24px | 1.5rem | |
--space-2xl | 32px | 2rem |
The scale stays fixed across themes and the default and helm presentation sizes. Helm typography grows for the viewing distance; it does not multiply the gaps. Browser zoom enlarges the complete interface.
Spacing recipes
These are starting points for ordinary interface content. Preserve the relationship when a layout wraps or changes columns. Use a larger step for separation before adding a border.
| Relationship | Step | Where it belongs |
|---|---|---|
| Label to field; field to hint or error; title to supporting detail | xs | Inside the field or text group |
| Icon to label; actions in one group | sm | Inside the control or its action row |
| Compact card inset; horizontal input inset | md | On the card or control |
| Between form fields; roomy panel inset | lg | On the form stack or panel |
| Between sections or separate cards | xl | On their parent stack |
| Wide page gutter or major separation | 2xl | On the page layout, reduced on narrow screens |
Cards use md internally, and a metric card the chart inset role --bk-metric-card-inset. Forms use xs for a field's label and help, with lg between fields.
Buttons use their own padding and minimum target; adding an outer gap does not make the button easier to hit.
A token can appear in more than one recipe without needing another name for the same distance.
Compose with primitives
Stack space="lg"owns vertical space between siblings.Inline space="sm"owns horizontal space between siblings. It keeps one row; use a wrapping flex or grid layout with the same gap token when children must wrap.Box padding="md"owns the inset around its content.paddingBlockandpaddingInlineoverride their axes. An unpadded nested box has no additional inset.- Use
gapfor repeated siblings. Use padding for container insets. A local margin is appropriate for a component part, such as the hint under a field; page margins belong to the parent layout.
Use logical properties such as padding-inline when the space follows reading direction. Do not combine a
child's outer margin with a parent's gap to create the same separation twice. On narrow screens, reduce the
page gutter and wrap related actions before shrinking targets or introducing horizontal scrolling.
<Box padding="lg">
<Stack space="xl">
<Card title="Vessel details">
<Stack space="lg">{/* Fields own their labels and help. */}</Stack>
</Card>
<Card title="Before departure">{/* A separate section. */}</Card>
</Stack>
</Box>Try the relationships
Choose a small gap and a large inset, then reverse them. The guides show the panel's inset and the gap between cards. The cards, fields and buttons render their own styles. The presentation switch changes the text size while retaining the selected spacing steps. Reset returns the example to the recipes.
Panel inset
Between cards
Vessel details
Use the name on the vessel's documents.
Before departure
Review the route and prepare lines and fenders.
Accent guides show the outer inset and the space between cards. Changing text size keeps these spacing steps.
<Box padding="lg">
<Stack space="xl">
<Card title="Vessel details">…</Card>
<Card title="Before departure">…</Card>
</Stack>
</Box>Code
import { Box } from "@bosunkit/components/box";
import { Button } from "@bosunkit/components/button";
import { Card } from "@bosunkit/components/card";
import { FormField } from "@bosunkit/components/form-field";
import { Input } from "@bosunkit/components/input";
import { SegmentedRadioGroup } from "@bosunkit/components/segmented-radio-group";
import { Select } from "@bosunkit/components/select";
import { Stack, type StackProps } from "@bosunkit/components/stack";
import { Text } from "@bosunkit/components/text";
import { PresentationScope } from "@bosunkit/provider";
import tokens from "@bosunkit/tokens/tokens.json";
import type { SizeClass } from "@bosunkit/tokens/theme";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"Change the panel inset and the gap between real cards. Guides reveal those spaces; each card owns its internal layout.";
type Space = NonNullable<StackProps["space"]>;
const STEPS: readonly Space[] = ["xs", "sm", "md", "lg", "xl", "2xl"];
function SpacePicker({
label,
value,
onChange,
}: {
label: string;
value: Space;
onChange: (space: Space) => void;
}): ReactElement {
return (
<Stack space="xs">
<Text variant="label">{label}</Text>
<Select.Root
value={value}
onValueChange={(next: string | null): void => { ((next: string): void => {
const step: Space | undefined = STEPS.find((candidate: Space): boolean => candidate === next);
if (step !== undefined) onChange(step);
})(next ?? ""); }}
>
<Select.Trigger aria-label={label}>
<Select.Value />
</Select.Trigger>
<Select.Portal><Select.Positioner alignItemWithTrigger={true} ><Select.Popup ><Select.List>
{STEPS.map((step: Space): ReactElement => (
<Select.Item key={step} value={step}>
{step} · {tokens.base[`space-${step}`]}
</Select.Item>
))}
</Select.List></Select.Popup></Select.Positioner></Select.Portal>
</Select.Root>
</Stack>
);
}
export default function Example({ compact = false }: { compact?: boolean }): ReactElement {
const [inset, setInset]: [Space, Dispatch<SetStateAction<Space>>] = useState<Space>("lg");
const [gap, setGap]: [Space, Dispatch<SetStateAction<Space>>] = useState<Space>("xl");
const [size, setSize]: [SizeClass, Dispatch<SetStateAction<SizeClass>>] = useState<SizeClass>("default");
const [guides, setGuides]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(true);
function reset(): void {
setInset("lg");
setGap("xl");
setSize("default");
setGuides(true);
}
return (
<div role="group" aria-label={compact ? "Spacing overview" : "Spacing playground"} style={{ width: "100%" }}>
<Stack space="lg">
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: "var(--space-md)" }}>
<SpacePicker label="Panel inset" value={inset} onChange={setInset} />
<SpacePicker label="Between cards" value={gap} onChange={setGap} />
</div>
{!compact && <SegmentedRadioGroup
label="Presentation size"
value={size}
motion={false}
options={[
{ value: "default", label: "Default" },
{ value: "helm", label: "Helm" },
]}
onValueChange={(value: string): void => {
setSize(value === "helm" ? "helm" : "default");
}}
/>}
{!compact && <div style={{ display: "flex", flexWrap: "wrap", gap: "var(--space-sm)" }}>
<Button
textCase="none"
motion={false}
aria-pressed={guides}
onClick={(): void => {
setGuides(!guides);
}}
>
Show spacing guides
</Button>
<Button textCase="none" motion={false} onClick={reset}>
Reset spacing
</Button>
</div>}
<PresentationScope sizeClass={size}>
<Box background={guides ? "accent" : "surface2"} padding={inset} radius="md">
<Stack space={gap}>
<Card title="Vessel details" headingLevel={2}>
<Stack space="lg">
<FormField label="Vessel name" hint="Use the name on the vessel's documents.">
<Input defaultValue="Windgrazer" />
</FormField>
{!compact && <FormField label="Home port">
<Input defaultValue="Brest" />
</FormField>}
</Stack>
</Card>
<Card title="Before departure" headingLevel={2}>
<Text>Review the route and prepare lines and fenders.</Text>
</Card>
</Stack>
</Box>
</PresentationScope>
{!compact && <Text variant="caption" color="text-dim">
Accent guides show the outer inset and the space between cards. Changing text size keeps these spacing steps.
</Text>}
{!compact && <pre style={{ margin: 0, whiteSpace: "pre-wrap", font: "var(--bk-type-code)" }}>
{`<Box padding="${inset}">\n <Stack space="${gap}">\n <Card title="Vessel details">…</Card>\n <Card title="Before departure">…</Card>\n </Stack>\n</Box>`}
</pre>}
</Stack>
</div>
);
}
Vessel details
Vessel name and home port
Before departure
Route, lines and fenders
Vessel details
Vessel name and home port
Before departure
Route, lines and fenders
Touch targets
A control used by touch has at least the shared touch-target size in both dimensions: 48 px at the default size class and 54 px at helm, where the text grows by the same 18/16. The icon or mark inside it can be smaller. The visual-size token describes control surfaces, not the minimum size of every glyph. An invisible extension of a target must remain inside its layout allocation and must not overlap a neighbor.
| Token | Value | rem | Preview |
|---|---|---|---|
--target-touch-size | 48px | 3rem | |
--target-visual-size | 32px | 2rem |
--target-touch-size · 48px
The icon is smaller than the area that accepts a press.
Example unchecked.
Code
import { IconButton } from "@bosunkit/components/icon-button";
import { Stack } from "@bosunkit/components/stack";
import { Text } from "@bosunkit/components/text";
import { Check } from "lucide-react";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
import tokens from "@bosunkit/tokens/tokens.json";
export const description: string =
"The dashed frame is the button's full target. Press near the edge or activate it with the keyboard.";
export default function Targets(): ReactElement {
const [checked, setChecked]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
return (
<Stack space="md">
<div style={{ display: "flex", alignItems: "center", flexWrap: "wrap", gap: "var(--space-md)" }}>
<div
style={{
width: "var(--target-touch-size)",
minHeight: "var(--target-touch-size)",
outline: "1px dashed var(--accent)",
borderRadius: "var(--radius-md)",
}}
>
<IconButton
label="Mark the example checked"
icon={<Check size="1em" />}
variant="ghost"
motion={false}
aria-pressed={checked}
onClick={(): void => {
setChecked(!checked);
}}
/>
</div>
<Text variant="code">--target-touch-size · {tokens.base["target-touch-size"]}</Text>
</div>
<Text as="p" variant="caption">
The icon is smaller than the area that accepts a press.
</Text>
<div role="status">
<Text>{checked ? "Example checked." : "Example unchecked."}</Text>
</div>
</Stack>
);
}
Buttons, tabs, switches, chips (and their remove buttons) and fields take the touch size on a screen up to 68.75em
or under @media (pointer: coarse); a coarse pointer changes a target's size, never a layout. Fine-pointer desktop
controls can be more compact. A portaled list sits in the page body, outside every screen container, so its choices
take the touch size under a coarse pointer, whatever the trigger's screen. A larger font or an empty gap beside a control does
not increase its hit area. Test the bounds and actual edge presses, not only a screenshot.
Geometry and exceptions
Spacing tokens govern interface layout. Border widths, focus-ring compensation, icon alignment, measured text adornments, whole-line editor resizing and instrument drawing coordinates have their own geometry. Keep those calculations beside the part they align; explain why a regular spacing step would move or obscure it.
Negative spacing is reserved for a documented alignment or hit-area calculation, such as extending a table header control back through its cell inset. Express that inset through the same token. Do not use negative margins to repair double spacing between components.
The component foundation tests check nonzero CSS padding, margins and gaps, including values that happen to be on the grid, plus numeric and string values in inline React styles. Font-relative layout gaps also use the scale. Geometry exceptions name their file, property, exact value and reason; unused exceptions fail the check. There is no allowance for a count of arbitrary spacing values.
References
Atlassian spacing describes grouping, hierarchy, rhythm and optical adjustment. The kit uses its own six-step scale and touch-target tokens.
Related
- Box: container insets and nested padding
- Stack: spacing between vertical siblings
- Grid: responsive layouts and screen containers
- Accessibility: keyboard access and target behavior