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/Gridonly 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
- 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).
- 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. - Semantics preserved. A free wrapper between
ulandli, or betweentableandtr, breaks the structure screen readers rely on to navigate. - 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:
| Component | Flex container? | Spacing (m*/p*/gap) | Note |
|---|---|---|---|
Flex, Grid | yes | yes | the DS's layout containers |
Paper, Card | yes — built on Flex | yes | inherit every Flex layout prop |
Button | yes — FlexFactory on the button itself | yes | icon/text alignment already resolved internally |
Text | yes — via apollion.span | yes | accepts the full factory set |
Label | no | yes | typography + spacing + border |
Image | no | no | sized via width/height/full/cover |
Link | no | no | container/typography/color only — see Known gaps |
Meter | root is a Flex | passes at runtime | the 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
| Do | Don't |
|---|---|
Flex/Grid with ≥2 children on the axis or a direction change | A Flex with a single child just to align, pad, or set mt |
alignSelf / justifySelf / area / spacing on the child itself | A 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 Flex | An 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 (header → main → footer).
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
altor the link's accessible name.
Escape hatches
When the leaf genuinely doesn't expose the prop:
apollion.<tag>— the factory elements (apollion.a,.div,.span,.button,.input) accept every factory, including flex and spacing. SwappingLinkforapollion.afixes the layout, but you lose whatLinkgives you for free: automatictarget/relon external URLs and readable color against the surface. Use it when the element is decorative, not when it's real navigation.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:
- Find
<Flexwith a single child and no axis change — promote the props onto the child and delete the wrapper. - Swap nested
FlexforGridwhen the layout is a matrix, not a 1D list. - Confirm in the computed style that each layout prop actually painted — unimplemented props disappear without warning.
- 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:
Linkexposes neither spacing nor flex. Accepts only container (display,position, raw-CSSpadding/margin), typography and color. Meanwhile, use a single child,apollion.a, or a wrapping container.Meterdoesn't declare layout props in the interface. The root is aFlexand 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.