Skip to content

repro(Container): demonstration of responsive form layout limits - #1172

Draft
DreaminDani wants to merge 1 commit into
mainfrom
dani/container-responsive-repro
Draft

repro(Container): demonstration of responsive form layout limits#1172
DreaminDani wants to merge 1 commit into
mainfrom
dani/container-responsive-repro

Conversation

@DreaminDani

@DreaminDani DreaminDani commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Why?

Responsive forms are pretty much impossible to do correctly with our current primitives. For example, the payment info form in onboarding needs a form shaped like this: full-width address lines, two rows of side-by-side fields, a toggle row, and a footer with Back on the left and the primary action on the right.

image

In Figma that is a stack of auto-layouts. In a browser it also has to survive the card getting narrower, at which point the side-by-side fields need to stack.

I tried to build it with Container and with GridContainer and could not get a correct result from either without switching off the isResponsive prop and overriding the flex/grid properties directly.

This PR is the reproduction only. It changes no library code, so the Storybook preview shows how main behaves today.

How?

One new stories file: src/components/Container/ResponsiveFormLayout.stories.tsx, under Layout > Responsive Form Layout.

CompareAll builds the same form four ways and gives each one a containerWidth control, so card width and window width can be varied independently. That separation is the thing to look at.

  • A: nested Container, library defaults
  • B: nested Container, isResponsive={false} with wrap / grow / minWidth
  • C: one GridContainer, gridTemplateColumns="1fr 1fr" with isResponsive
  • D: one GridContainer, isResponsive={false} with repeat(auto-fit, minmax(200px, 1fr))

City and Zip field widths, measured in Chromium:

approach vp 1400 / card 360 vp 1400 / card 720 vp 760 / card 720
A 172px, side by side 352px 720px, footer stacked
B 360px, stacked 352px 352px
C 172px, side by side 352px 512px / 192px
D 360px, stacked 229px across 3 columns 229px across 3 columns

Three more stories isolate the primitive-level defects behind those numbers.

isResponsive is keyed to the viewport and never to the container. At a 360px card on a 1400px window, A and C leave the fields side by side at 172px and clip the primary button. Inside a dialog or a side panel the card width is the only thing that matters. Because the prop also defaults to true, at 768px every Container flips to flex-direction: column, so approach A stacks Back on top of the primary button and drops the toggle label below the switch, with 720px of room available.

Container padding overflows its parent. .container sets width: 100% and padding with no box-sizing: border-box, and the library ships no global reset (global.css is a single body color rule). <Container fillWidth padding="lg"> inside a 360px parent measures 408px. The overflow equals the padding, and it is invisible in any app that ships its own reset, which is probably why it has survived.

isResponsive discards maxWidth. .container_responsive sets max-width: none below 768px. A Container asking for maxWidth="480px" inside a 900px parent measures 482px at a 1000px viewport and 902px at 700px.

Underneath all three sits an API problem rather than a CSS one: GridContainer children cannot describe their own placement, and neither component accepts any instruction about what to do at a given breakpoint. That is what the recommendations below are about.

Recommendations, for a separate PR

The first two are API shape, the third is the mechanism both of them would need.

1. GridContainer gives children no way to place themselves

A child cannot say how many columns it spans. The full-width rows in this repro need style={{ gridColumn: 'span 2' }}, which leaves the design system entirely, and those inline spans are what create the implicit auto track that garbles the isResponsive collapse into 512px 192px. The one prop that would fix it does not exist.

MUI's Grid puts the span on the child, and accepts breakpoint objects there:

<Grid size={{ xs: 12, sm: 6, md: 8 }}>...</Grid>
<Grid size="grow">...</Grid>   // equal share of the remaining space
<Grid size="auto">...</Grid>   // fit to content
<Grid size={4} offset={{ xs: 3, md: 0 }}>...</Grid>

That would express this form directly: size={{ xs: 12, md: 6 }} on City and Zip, size={12} on the address lines, no inline styles and no 1 / -1 strings.

2. Neither component accepts per-breakpoint instruction

isResponsive is one boolean covering one hardcoded breakpoint. There is no way to say "two columns here, one column below that", so the escape hatch is a raw CSS string in gridTemplateColumns, which is why approach D ends up with three columns at a 720px card.

MUI takes a responsive object on every layout prop, not just child spans:

<Grid container columns={16} spacing={{ xs: 2, md: 3 }} columnSpacing={{ xs: 1, sm: 2 }}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={{ xs: 1, md: 4 }}>

Stack is the closest analogue to our Container, and direction taking a breakpoint object is the thing orientation cannot do today. A footer row would set orientation="horizontal" flat and never stack, instead of needing isResponsive={false} to opt out of a default it never wanted.

3. Key the reflow to the container, not the viewport

Both of the above still leave the breakpoints keyed to the window. Two references for fixing that:

shadcn's Field has orientation="vertical" | "horizontal" | "responsive", and responsive resolves against a container query rather than the viewport. The parent opts in by naming a containment context: <FieldGroup className="@container/field-group ...">. Underneath it is Tailwind v4, where @container marks the container and @md:flex-row, @max-md:flex-col, @sm:@max-md:flex-col and @min-[475px]:flex-row set the conditions. shadcn is copied source plus utility classes rather than a component API, so the transferable part is the mechanism and the orientation="responsive" naming, not the implementation.

MUI v6 added theme.containerQueries, mirroring the existing theme.breakpoints methods so the same breakpoint keys work either way:

theme.containerQueries.up('sm')            // @container (min-width: 600px)
theme.containerQueries.down('md')          // @container (max-width: 900px)
theme.containerQueries('sidebar').up('500px')  // @container sidebar (min-width: 500px)

We already have the token half of this: --breakpoint-sizes-sm through -2xl exist and only md is used.

Concretely for us: container-type: inline-size on both components, re-key the existing 768px rules to @container, and let callers name a breakpoint token instead of accepting the hardcoded one. .browserslistrc is not a blocker; the oldest target is Safari/iOS 16.0, and container queries shipped in Safari 16.0 and Chrome 105.

The two mechanisms compose rather than compete, which is what MUI does: responsive objects for the props, container queries for what those breakpoints resolve against. If we take both, orientation={{ base: 'vertical', md: 'horizontal' }} and size={{ base: 12, md: 6 }} would read against container width.

Smaller fixes in the same area

box-sizing: border-box on both base classes. A padding prop on GridContainer, which has none. And a decision on isResponsive itself: defaulting to true is wrong for any row that should never stack, so the default and the name are both open questions.

On the two approaches in this repro

Approach B is correct at every width I tested, and it gets there by turning isResponsive off on all ten Containers and reimplementing reflow with flex wrap. A plain div would do the same. Approach D reflows on container width but mis-groups: at a 720px card, auto-fit produces three columns and orphans State / Province, because a track list cannot express "these two fields belong together." A child-side size prop can.

References

Tickets?

None yet. Filing follow-ups once there is agreement on the direction.

Contribution checklist?

  • You've done enough research before writing
  • You have reviewed the PR
  • The commit messages are detailed
  • The build command runs locally (not run; stories-only change, tsc --noEmit and eslint are clean)
  • Assets or static content are linked and stored in the project
  • For documentation, guides or references, you've tested the commands

Security checklist?

  • All user inputs are validated and sanitized
  • No usage of dangerouslySetInnerHTML
  • Sensitive data has been identified and is being protected properly
  • Build output contains no secrets or API keys

Preview?

Storybook preview on this PR, under Layout > Responsive Form Layout. Start with CompareAll, then resize the window across 768px while leaving containerWidth alone.

Note for reviewers: these stories render deliberately broken layouts, so they will show up as new Chromatic snapshots needing approval.

Adds a Storybook-only reproduction of a two-column form layout (the
Billing information onboarding step) built four ways with Container and
GridContainer, so the failure modes can be compared at a fixed card width
while the viewport changes independently.

Also adds three isolated repros for the primitive-level defects the
comparison runs into:

- Container sets width:100% plus padding with no box-sizing:border-box,
  and the library ships no global reset, so a padded Container overflows
  its parent by the padding amount.
- .container_responsive sets max-width:none below 768px, so a Container's
  own maxWidth is discarded below the breakpoint.
- isResponsive is keyed to the viewport rather than the container, and
  defaults to true, so every Container flips to column at 768px including
  footer rows using justifyContent="space-between".

No library code changes. Stories only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Aug 27, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 504d022

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@workflow-authentication-public

Copy link
Copy Markdown
Contributor

Storybook Preview Deployed

✅ Preview URL: https://click-gqkymjrwu-clickhouse.vercel.app

Built from commit: 898989ceb9863143fd665762b1bc017160750b0d

@DreaminDani DreaminDani changed the title chore(Container): add repro for responsive form layout limits repro(Container): add repro for responsive form layout limits Aug 27, 2026
@DreaminDani DreaminDani changed the title repro(Container): add repro for responsive form layout limits repro(Container): demonstration of responsive form layout limits Aug 27, 2026
@XOP XOP added the demo only Only demonstration, all code changes in the follow up label Sep 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

demo only Only demonstration, all code changes in the follow up

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants