Docs
Composition

Composition

Layout props answer how to declare spacing, color and alignment. This page answers the prior question: where those props should live — and how many elements your screen actually needs to reach the same design.

The rule came out of audits on products consuming Apollion (the case detailed below is the Fanático FC radar). The pattern repeats: screens are born with a staircase of Flex that exists only to push a single child, and every step is one more DOM node, one more place layout can be silently overridden.

TL;DR

An Apollion leaf component is already a flex container. Create a Flex/Grid only when there are two or more children on the same axis or a direction change. A single child's spacing and alignment belong on that child — not on a wrapper around it.

Why less DOM

  1. Cheaper SSR and hydration. Every wrapper is one more mounted styled-component, one more generated class, one more node for React to reconcile. The gain shows up on dense pages (lists, cards, tables).
  2. A readable tree. The JSX becomes the drawing of the screen. A staircase of generic divs forces the reader to simulate the CSS in their head.
  3. Semantics preserved. A free wrapper between ul and li, or between table and tr, breaks the structure screen readers rely on to navigate.
  4. One source of truth per visual rule. With spacing on the component itself, there's a single place to change it — not three wrappers competing over who owns gap.

What each leaf already handles

Before wrapping, check what the component accepts directly:

ComponentFlex container?Spacing (m*/p*/gap)Note
Flex, Gridyesyesthe DS's layout containers
Paper, Cardyes — built on Flexyesinherit every Flex layout prop
Buttonyes — FlexFactory on the button itselfyesicon/text alignment already resolved internally
Textyes — via apollion.spanyesaccepts the full factory set
Labelnoyestypography + spacing + border
Imagenonosized via width/height/full/cover
Linknonocontainer/typography/color only — see Known gaps
Meterroot is a Flexpasses at runtimethe interface doesn't declare the props — no autocomplete/type-check

When the component accepts the prop, it goes there. When it doesn't, that's when a wrapper is actually justified.

The rule

DoDon't
Flex/Grid with ≥2 children on the axis or a direction changeA Flex with a single child just to align, pad, or set mt
alignSelf / justifySelf / area / spacing on the child itselfA Flex around Button/Label/Text as an "empty container"
Grid for 2D regions (cards, KPIs, footer columns)Nested Flex mimicking a 2D page shell
Paper/Card as the axis itself — they're already FlexAn extra Flex column because the parent is already a column

Cases where the wrapper stays: as="li"/as="nav" for semantics; visual clipping (borderRadius + overflow) around Image/Svg; a surface with its own bgColor; a 1D page shell (headermainfooter).

A single-child wrapper, and the same result without it
// bad — Flex exists only to carry the mb
<Flex mb="medium">
  <Button text="Save" />
</Flex>
 
// good — the spacing lives on the Button itself
<Button text="Save" mb="medium" />

Real case — the site's partner band

The home page's "Who uses the Design System" band was born with a Link wrapping a logo + caption, trying to stack the two with flex props:

// bad — Link has no FlexFactory or SpacingFactory:
// flexDirection, alignItems, gap and p are silently dropped.
<Link href="…" display="flex" flexDirection="column" alignItems="center" gap="small" p="medium">
  <img src="/partners/fanatico-fc-logo.png" alt="Fanático FC" />
  <Text variant="meta">Fanático FC · Radar Seleção 2030</Text>
</Link>

The logo and the caption came out glued side by side — not stacked. The fix wasn't adding another Flex: it was removing the second child. The caption was already the image's description, so it became the alt — which also became the link's accessible name:

// good — a single child, no axis to declare
<Link href="…" textDecoration="none" display="flex">
  <Image src="/partners/fanatico-fc-logo.png" alt="Fanático FC · Radar Seleção 2030" width={160} height={51} />
</Link>

Two lessons that apply to any consumer:

  • An ignored prop is worse than a missing one. A layout prop the component doesn't implement doesn't error — it simply doesn't paint. Check the computed style when the result doesn't match the JSX.
  • Redundant text is often accessibility content in disguise. Before creating an axis to hold a caption, check whether it's already the image's alt or the link's accessible name.

Escape hatches

When the leaf genuinely doesn't expose the prop:

  1. apollion.<tag> — the factory elements (apollion.a, .div, .span, .button, .input) accept every factory, including flex and spacing. Swapping Link for apollion.a fixes the layout, but you lose what Link gives you for free: automatic target/rel on external URLs and readable color against the surface. Use it when the element is decorative, not when it's real navigation.
  2. style={{…}} — last resort, and only for what no factory covers. Mark the spot in your code (an [Apollion] comment is enough) so it becomes a DS prop request instead of silent debt.

Audit checklist

To run on your product, in order:

  1. Find <Flex with a single child and no axis change — promote the props onto the child and delete the wrapper.
  2. Swap nested Flex for Grid when the layout is a matrix, not a 1D list.
  3. Confirm in the computed style that each layout prop actually painted — unimplemented props disappear without warning.
  4. List whatever style={{…}} is left and send it to the DS: each one is a prop candidate.

Known gaps

Surfaced by consumer audits, still open in the DS:

  • Link exposes neither spacing nor flex. Accepts only container (display, position, raw-CSS padding/margin), typography and color. Meanwhile, use a single child, apollion.a, or a wrapping container.
  • Meter doesn't declare layout props in the interface. The root is a Flex and the props reach it via spread, but untyped — no autocomplete and no compile error on a bad value.

Both are known gaps on the roadmap.