CogniPrep runs timed practice assessments. Each screen has a stimulus (a table, a chart, a matrix, a passage) and a set of answers, and they were all stacked in one narrow centred column. On a laptop that wastes the right half of the screen and pushes the answers, sometimes including the submit button, below the fold. A candidate on a per-item clock should not have to scroll before they can answer.
The obvious fix is a two-column grid from lg up. The constraint that made it interesting: 44 screens already worked correctly on mobile, and the refactor was not allowed to change any of them by a pixel.
Byte-identical by construction, not by inspection
Every class the component adds for the split is lg: prefixed. Below 1024px the markup renders as a width-constrained wrapper with space-y-*, containing two plain divs that each carry the same space-y-*. Tailwind's space-y-* compiles to a margin 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 the split existed.
That reasoning is what let the change go out across 44 files at once. "I checked them all on my phone" does not scale to 44 screens; "the mobile branch emits the same box model it emitted before" does.
The subtle part is the order of the class names:
className={cn(
'w-full',
mobileWidthClassName,
spacingClassName,
// lg:space-y-0 must come after spacingClassName so tailwind-merge drops
// the lg variant of the rhythm above. Without it, the margin-block-end
// that space-y puts on every non last child lands on the grid items and
// pushes the answers column a rem down its own track.
'lg:grid lg:items-start lg:space-y-0',
RATIO_CLASSES[ratio],
gapClassName,
desktopWidthClassName,
className
)}
If a caller passes space-y-4 lg:space-y-6, the lg:space-y-6 has to lose to lg:space-y-0 once the element becomes a grid, and with tailwind-merge that is decided by position. The bug it prevents is the answers column sitting one rem lower than the stimulus column for no visible reason.
Two more one-liners that are pure grid tax:
<div className={cn('min-w-0', spacingClassName, stimulusClassName)}>{stimulus}</div>
Grid items default to min-width: auto, so a wide data table or one long unbroken string blows its track out and drags the other column with it. min-w-0 on both tracks is not optional.
stickyAnswers && 'lg:sticky lg:top-6 lg:self-start'
A stretched grid item is already as tall as its row, so it has nowhere to stick. self-start is what makes position: sticky do anything at all inside a grid. And sticky is only offered when the answers are clearly shorter than the viewport, because a sticky element taller than its scrollport pins at the top and its own bottom becomes unreachable.
The ratios are written out in full, on purpose
const RATIO_CLASSES = {
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)]',
};
Complete literal strings, never built by interpolation, because Tailwind finds classes by scanning source text. A composed class name is a class name that exists in your head and not in the stylesheet. The minmax(320px,1fr) in the middle variant is the floor that stops an option list from being squeezed into a column too narrow to read when the stimulus is greedy.
Three global rules the component has to live with
This is the part I would tell anyone adding a layout primitive to an app with a mature stylesheet: read the global CSS before you write the component, because it has opinions.
Scrollbars are hidden app-wide. So the component deliberately adds no overflow-* and no fixed heights. A nested scroll region here would be invisible to the user, and there is exactly one scroll container in the game shell by design. The columns grow, the shell scrolls, which is what happened before the split too.
main carries contain: layout style paint. Containment makes it a containing block, so position: fixed inside a game resolves against main rather than the viewport. Anything that wants to stay put must use sticky. This is the kind of thing that costs an hour of confusion if you do not know it and zero minutes if you do.
Cards cannot have borders. There is an unlayered rule in the global stylesheet: any element whose class attribute contains bg-card gets border: none !important. It exists because the utility supports opacity modifiers (bg-card/90), so matching the substring replaces a pile of individual overrides. The consequence for a new layout is real: if a column needs a visible edge, it has to come from a different background utility with an explicit border, or from a one-pixel divider element.
See it for yourself. Open cogniprep.app/games and inspect one of the provider cards. Its class attribute contains both bg-card and border, and its computed style is border-style: none with border-width: 0px. In the console:
const el = [...document.querySelectorAll('*')].find(e => /bg-card/.test(e.className || ''));
getComputedStyle(el).borderWidth; // "0px", despite the border utility
While you are in there, check getComputedStyle(document.documentElement).overflowY. It is scroll, deliberately, so the page does not shift horizontally when content grows, with the scrollbar itself hidden.
The games themselves are behind a free account, so the split layout is not something you can inspect anonymously. The two things you can check from outside are the constraints it was written against, and those two rules are the reason this component contains no overflow property and no fixed positioning.
Top comments (0)