Skip to content
design
Foundations

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.

Adjust the inset and the gap between real cards, with guides showing who owns each space.
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>
  );
}
01Choose by relationshipRelated text sits close together. Controls in one task share a group. Separate sections have more room between them.
02Give each space one ownerA container owns its inset. A parent layout owns the gaps between children. Nested containers apply only their own padding.
03Keep targets independentA compact label can sit inside a generous touch target. Text size, spacing and the area that accepts a press have separate jobs.

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.

TokenValueremPreview
--space-xs4px0.25rem
--space-sm8px0.5rem
--space-md12px0.75rem
--space-lg16px1rem
--space-xl24px1.5rem
--space-2xl32px2rem

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.

RelationshipStepWhere it belongs
Label to field; field to hint or error; title to supporting detailxsInside the field or text group
Icon to label; actions in one groupsmInside the control or its action row
Compact card inset; horizontal input insetmdOn the card or control
Between form fields; roomy panel insetlgOn the form stack or panel
Between sections or separate cardsxlOn their parent stack
Wide page gutter or major separation2xlOn 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. paddingBlock and paddingInline override their axes. An unpadded nested box has no additional inset.
  • Use gap for 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

DefaultHelm

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>
Change the panel inset and the gap between real cards. Guides reveal those spaces; each card owns its internal layout.
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>
  );
}
Do

Vessel details

Vessel name and home port

Before departure

Route, lines and fenders

Keep each title close to its detail, with more room between the groups.
Don't

Vessel details

Vessel name and home port

Before departure

Route, lines and fenders

Equal gaps inside and between groups hide their relationships.

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.

TokenValueremPreview
--target-touch-size48px3rem
--target-visual-size32px2rem

--target-touch-size · 48px

The icon is smaller than the area that accepts a press.

Example unchecked.

The dashed frame is the button's full target. Press near the edge or activate it with the keyboard.
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.

  • Box: container insets and nested padding
  • Stack: spacing between vertical siblings
  • Grid: responsive layouts and screen containers
  • Accessibility: keyboard access and target behavior