CogniPrep simulates employer psychometric tests. Most of those tests are the same shape: a stimulus (a matrix, a data table, a chart, a passage) and then a set of answer options and a submit button.
The natural way to build that in React is to stack them, put the stack in a max-w-md column, and centre it. Which is what 44 of our game components did. On a phone it is right. On a laptop it is this: a 480px ribbon of content down the middle of a 1440px screen, with the whole right half empty, and the options and sometimes the button pushed below the fold.
That is not merely ugly. These tests run on a per item clock. If a candidate has to scroll before they can answer, the clock is charging them for scrolling.
So the fix is a two column layout from lg up. The interesting part is not the grid. It is everything that had to be guaranteed around it.
Constraint one: mobile must not move at all
44 game components, each already verified at 320px, each with its own width classes and vertical rhythm. A refactor that improved the desktop and quietly nudged the mobile layout of 44 screens would be a bad trade, and nobody would find out until a candidate on a phone lost a minute to it.
So the component is written such that below 1024px it is provably a no-op. Every class it adds for the split is lg: prefixed. What renders on a phone is w-full <your widths> space-y-4 around two plain divs that each carry the same space-y-*.
That produces the identical vertical rhythm a flat sibling list produced, and the reason is worth knowing: Tailwind v4 space-y-* compiles to margin-block-end on every child but the last. So the gap between the last stimulus element and the first answers element is the same one rem it was before, because the nesting does not change which elements are adjacent in the flow.
The caller passes its own values in rather than accepting a house default:
<GameSplitLayout
mobileWidthClassName="max-w-md sm:max-w-lg" // copied verbatim from the old wrapper
spacingClassName="space-y-4 lg:space-y-6" // the game's existing rhythm
desktopWidthClassName="lg:max-w-6xl"
ratio="wide-stimulus"
stimulus={<Matrix ... />}
answers={<Options ... />}
action={<SubmitButton ... />}
/>
Verbose on purpose. A component that guessed the mobile widths would be a component that changes 44 screens.
Constraint two: the one class order that bites
className={cn(
'w-full',
mobileWidthClassName,
spacingClassName,
'lg:grid lg:items-start lg:space-y-0',
...
)}
lg:space-y-0 has to come after spacingClassName so tailwind-merge drops the lg variant of the rhythm above it. Without it, the margin-block-end that space-y puts on every non last child lands on the grid items themselves and pushes the answers column a rem down its own track. The grid gap is doing that job now; the flow spacing has to get out of the way at exactly the breakpoint the grid turns on.
Constraint three: grid tracks want to be as wide as their content
<div className={cn('min-w-0', spacingClassName, stimulusClassName)}>{stimulus}</div>
Grid items default to min-width: auto, which means a wide data table or a long unbroken string blows the track out rather than being constrained by it. Our shell is overflow-hidden, so that overflow is clipped, not scrollable, which is the worst of both: content is gone and there is no way to reach it. min-w-0 on both tracks, every time.
Constraint four: the ratios must be literal strings
const RATIO_CLASSES: Record<GameSplitRatio, string> = {
balanced: 'lg:grid-cols-2',
'wide-stimulus': 'lg:grid-cols-[minmax(0,1.35fr)_minmax(320px,1fr)]',
'wide-answers': 'lg:grid-cols-[minmax(0,1fr)_minmax(0,1.35fr)]',
};
Written out in full, never interpolated, because Tailwind's source scanner is a text scanner. A class assembled at runtime from parts is a class that does not exist in the stylesheet. Three presets, chosen by what the test actually shows: wide-stimulus for data tables and eight cell matrices, wide-answers for five point scales and options that are whole sentences, balanced for everything else.
Constraint five: inherited rules from the shell
This is the part you cannot work out from the component in isolation, and the reason it is documented at the top of the file rather than in a commit message.
There is exactly one scroll container. The game shell's <main> is min-h-0 flex-1 overflow-auto, and the app hides every scrollbar globally. So this component adds no overflow-* and no fixed heights. A nested scroll region here would be invisible to the user, would break the sticky bottom-0 strips several games use, and would break the scrollIntoView calls that reset the view between items. The columns grow, and main scrolls, exactly as before.
position: fixed does not mean the viewport. That main carries contain: layout style paint, which makes it a containing block. Anything fixed inside a game resolves against main, not the window. Use sticky.
Borders are not available on some elements. Our global stylesheet contains an unlayered [class*='bg-card'] { border: none !important }. It matches by substring, so anything whose class attribute merely contains bg-card cannot have a border, including the <Card> component. If a column needs a visible edge: bg-muted/40 with border-border border, or a one pixel bg-border element. This rule cost two people an afternoon each before it was written down.
The sticky option, and when not to use it
stickyAnswers && 'lg:sticky lg:top-6 lg:self-start'
lg:self-start is what makes sticky work at all here. A stretched grid item is already as tall as its row, so it has nowhere to stick to and the property silently does nothing.
The flag is off by default because sticky has a failure mode that only shows up on short viewports: an element taller than its scrollport pins at the top and its own bottom becomes unreachable. On a game screen, the unreachable part is the submit button. So it is only turned on where the answers plus the action are clearly shorter than the viewport, which in practice means multiple choice lists, not long option sets.
Look at it
Open a provider hub, for example Test Partnership or Sova, sign up on the free tier and start any test with a table or a matrix in it.
Then drag your browser window narrower through 1024px and back. The layout should snap between one column and two, and the one column state should be indistinguishable from what a phone gets, because it is the same markup with the lg: rules switched off.
If your own app has a "mobile first" component you are afraid to touch, the useful exercise is the one this forced: write down what must be byte identical below the breakpoint, then make the code structurally incapable of breaking it, rather than testing for it afterwards.
Top comments (0)