Browse all of Kata

Adaptive layout

Source docs/design/foundations/layout.mdMarkdown

On this page

Breakpoints, the column a screen sits in, when two panes earn their place, and why the tab bar stays at the bottom on an iPad.

Part of the Konjo design language. The laws in the spine apply here unless this chapter contradicts them.

Kata decided that every screen has a designed tablet layout (the interview). The dojo's iPad at the front desk, the instructor's iPad on the mat, a parent's tablet on the couch — they all ran the phone layout stretched edge to edge: a 64pt row 1,180 points wide, a chevron a forearm away from its label, a hero slab the width of a whiteboard. This chapter is how that stops.

A phone does not change. Everything here switches on at breakpoint.tablet and is a no-op below it. If a phone screenshot moves by a pixel, the change is wrong.

Three size classes, read from the window

Class Window width Gutter What changes
Phone below breakpoint.tablet (700) spacing.page 20 Nothing. This is the layout every screen was designed on.
Tablet breakpoint.tablet 700 and up layout.gutterTablet 32 Content sits in a centred column; forms in a narrower one; grids add columns.
Wide breakpoint.wide 1024 and up layout.gutterWide 40 As tablet, plus list/detail screens may split into two panes.

Widths, not devices. An iPad in Split View or Slide Over reports a phone width and gets the phone layout, which is right: the space is what matters, not the hardware. Never branch on Platform.isPad, a model name, or a screen's physical size.

700, not 768. Class Mode shipped its tablet layout on a 700pt seam (useIsWide), and two thresholds a few dozen points apart would put an iPad mini in portrait (744) on the tablet layout for one screen and the phone layout for the next. There is one number. useIsWide now reads it from the token.

Read it from useLayout(), never from useWindowDimensions and a comparison you wrote yourself. It re-renders on rotation, on a Split View resize and on a browser window being dragged, and it hands back isTablet, isWide, gutter, contentMaxWidth and the two column styles below.

The column

On a tablet a screen's content sits in one centred column, layout.readableWidth (720) wide plus the screen's own spacing.page padding on each side. Past ~720pt a 16pt line runs well over ninety characters and a row's chevron drifts so far from its label that the two stop reading as one control. The column narrows further when the window does, so the visible gutter is never less than the size class's gutter: at 820 (iPad portrait) the text edge sits 50pt in; at 1180 (landscape) the column is centred with the page tint either side.

phone 390                tablet 820                           wide 1180
┌──────────┐   ┌──────────────────────────┐   ┌────────────────────────────────────┐
│ Title    │   │    Title             ◎ ⬤ │   │          Title                ◎ ⬤  │
│ ▓▓▓▓▓▓▓▓ │   │   ╭▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓╮ │   │         ╭▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓╮       │
│ Row    › │   │    Row               ›   │   │          Row                ›      │
│ Row    › │   │    Row               ›   │   │          Row                ›      │
└──────────┘   └──────────────────────────┘   └────────────────────────────────────┘
 edge to edge     column 760, gutters ≥32         column 760, centred on the tint

How a screen adopts it:

  • A scroll spreads columnStyle into its contentContainerStyle, after the screen's own style. It is undefined on a phone, so the phone's style is untouched. Do not wrap the scroll's children in a View instead — a gap on the container would then apply to one child.
  • A header above the scroll takes the same column so the title lines up with the content. ScreenHeader does this itself; a ChildHeader takes style={columnStyle}; a hand-drawn header row spreads columnStyle into its style.
  • Anything else outside the scroll — a search row, filter chips, a footer — goes in <ScreenContainer>, which is a bare View on a phone.
  • Anything sized from the window width must size from contentMaxWidth instead. It equals the window width on a phone and the column on a tablet. A three-up photo grid that divides the window by three overflows the column on an iPad; that was the profile media grid's bug.

A one-task form takes the narrower form column. Sign-in, the welcome screen's actions, a short form with one submit: formColumnStyle, layout.formWidth (480). A 48pt pill 720 wide is a banner, not a button, and a password field the width of the screen asks for a paragraph. A long form with sections (contact details, an incident report) stays in the readable column.

The hero becomes a contained block. On a phone the Hero cancels the page gutter and runs to the screen edges. In the column it can only reach the column's edges, and a square-cornered slab that stops short of the screen reads as a mistake — so at tablet widths it takes radius.card and reads as the block it now is. The component does this itself; a screen does nothing.

Type does not scale up. A tablet is held closer than a whiteboard and further than a phone, and the scale already reaches ~46pt with Dynamic Type. Same tokens, same sizes, same 44pt targets. More room is spent on columns and air, never on bigger words.

The feed keeps its own column. Social's feed is capped at layout.feedColumnMaxWidth (600) on every size class — a feed is narrower than a reading column on purpose, the way every feed is. Its top bar rides the same 600 column, so nothing there changes on a tablet.

Grids add columns, never stretch tiles

A tile grid (see tile grid) on a tablet runs in the wider gridColumnStyle — layout.gridWidth (1000) — and gains columns as it gains room. The tile stays near its 104pt basis; a 390pt-phone tile stretched to 260pt is a banner with a name in the corner, and the whole point of the grid is seeing everyone at once. Class Mode's check-in grid already works this way: it measures its own width and checkInColumns() decides how many fit.

The same holds for a two-up layout of cards: two per row stays two per row inside the column. Do not turn a phone's two-up into a tablet's five-up of cards carrying sentences.

Two panes, at wide only

<SplitView> puts a list and the record it opens side by side: the list between layout.splitListMinWidth (360) and layout.splitListMaxWidth (400), the detail taking the rest, a border.hairline between them. Below breakpoint.wide it renders the list alone and the row pushes, exactly as on a phone.

Only at wide. In iPad portrait (820) a 360pt list leaves the detail 460pt — narrower than the phone's detail once its own gutters are in, and every record would read cramped. Mail, Notes and Settings drop to one pane in portrait for the same reason.

When to split. When people move between records of one list, and seeing the list while reading one is the point: the roster and a student, messages and a conversation, a class's sessions and one session. Not for a tab home — a tab home is an index or a hero, and its rows open different kinds of thing. Not for a list whose rows open a modal task: a modal is a detour (see navigation), and it covers both panes.

How. useSplitView() answers "is the detail beside the list right now?". A row's onPress selects when it is, and pushes when it is not. Selection is the caller's state; the selected row is a selected state (red is legal there, per the spine). With nothing selected, the detail pane says what goes there in one plain sentence, left-aligned — never a centred illustration in a void. A deep link or a push notification still lands on the pushed detail screen, with a working back, on every size class.

Rotation does not lose the place. When a screen drops from two panes to one with a record selected — rotating to portrait, or a Split View narrowing — the screen pushes that record, so the person is still looking at what they were looking at. That is the caller's job; SplitView only lays out.

The tab bar stays at the bottom

On a tablet, Konjo keeps its bottom tab bar. It does not grow a side rail.

  • The shape is the same on both. Navigation is four base tabs plus a conditional Teach, each owning a stack. A rail would carry the same five destinations with nothing to add, so it would change where things are without changing what there is.
  • One body memory. An instructor uses their phone and the dojo's iPad in the same evening. The thumb that finds Teach at the bottom of the phone finds it at the bottom of the iPad, which matters most at the front desk, mid-class, with one free hand.
  • The column needs the width more. A rail costs 80–100pt of a portrait iPad's 820, and the column is what makes a tablet read well. At the bottom, the bar costs height, which a scroll has plenty of.
  • The desk is Studio's. The owner who wants a dense, rail-driven desktop has Konjo Studio, which has a rail (studio/metrics) because it has a dozen destinations. The app does not need to become a second Studio on a large screen.

Revisit only if the app grows past five top-level destinations, or a tablet-first role (a front-desk kiosk mode) needs navigation the phone does not have.

Rotation

The layout reads the window live, so every screen that adopts the column is ready to rotate. The app is still locked to portrait in app.json, so today an iPad shows portrait only. Unlocking it — tablet orientations on, phones still portrait — is a native configuration change that goes through the release gate, and it should ship once the remaining high-traffic screens have their column.

Never

  • A phone layout that moves when a tablet layout is added.
  • A screen comparing the window width to a number it typed itself.
  • Branching on the device model instead of the window width.
  • A grid that stretches its tiles instead of adding columns.
  • A row 1,180 points wide.
  • Two panes in iPad portrait.
  • A side rail on the app's tablet layout.
  • Bigger type because the screen is bigger.