Skip to content

Anchor modals to a fixed top offset instead of centring them vertically #166

Description

@OffCrazyFreak

What

Modals are vertically centred, so their top edge sits in a different place for every dialog and moves whenever the content height changes. Proposal: cap the height and anchor the top at a fixed offset, so the top edge is always in the same place and only the bottom edge varies with content.

Concretely: max-h-[80dvh] with a 10dvh top offset on desktop, max-h-[90dvh] with 5dvh on mobile. Those pairs are self-consistent, a dialog at full height still has equal margin above and below, so a tall dialog looks centred and a short one simply stops earlier.

Why the current behaviour is a problem

Position comes from Radix/shadcn's default in frontend/src/components/ui/dialog.tsx:63:

fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%]

Centring moves both edges whenever the height changes. In practice:

  • Postavke: switching tabs changes the body height, so the title, the tab row and the close button all jump under the pointer. Profil, Obavijesti, Preferencije and Sigurnost are all different heights.
  • Validation errors appearing or clearing resize the dialog.
  • The locations MultiSelect renders as an inline Collapsible below md (multi-select.tsx:47), so opening it grows the dialog and shifts the header upward.
  • Any async content settling after open does the same.

Top-anchored, none of these move anything the user is looking at. Only the bottom edge travels.

What other systems do

This is a real split, not a settled rule, and it is worth saying so plainly.

Top-anchored by default, centring is opt-in:

  • Bootstrap. "By default the Bootstrap modal window is aligned to the top of the page with some margin." Vertical centring is the opt-in .modal-dialog-centered modifier, and has been since v4. https://getbootstrap.com/docs/5.3/components/modal/
  • Ant Design. The centered prop defaults to false; the default presentation is a top offset, and centring is something you ask for. "You can use centered, style.top or other styles to set position of modal dialog." https://ant.design/components/modal

Those are two of the most widely deployed component libraries in existence, and both made top-anchoring the default.

Centred by default:

  • The native <dialog> element. The UA stylesheet centres it with inset-block-start: 0; inset-block-end: 0; margin: auto. Worth noting how: auto margins only centre while there is spare room, so a dialog taller than the viewport top-aligns automatically. Native <dialog> is really the hybrid, not pure centring.
  • Material 3 basic dialogs and Apple alerts are centred, though both also cap size hard and push long content to a full-screen dialog or a sheet instead (see Overflowing modal bodies give no sign there is more content below #165).

Supporting argument from the <dialog> standards discussion: on whether a dialog should re-centre as its content grows, "having the dialog move up and down when stuff is added at the bottom would be quite weird". That is exactly the failure being described here, and the conclusion was that anchoring is preferable.

Three things top-anchoring fixes for free

  1. Sub-pixel blur. translate(-50%, -50%) on an odd-height box resolves to a half pixel, and Chrome and WebKit render the half pixel, which softens text. A layout-positioned dialog never has the fractional offset. Long-standing and still current. https://www.alexbarker.me/post/blurry-html-with-transform-translate
  2. The safe-area refusal at modal-shell.tsx:179. The comment there declines safe-area padding on the footer because "the dialog is centred with a transform, so padding grows it about its centre and pushes the bottom edge toward the home indicator rather than away from it." Top-anchored, padding grows downward and that objection disappears.
  3. Keyboard behaviour. When dvh shrinks for the on-screen keyboard, a centred dialog slides its title upward as the box shortens. Top-anchored, the title stays put and only the body loses height, which is the behaviour the pinned-footer layout already assumes.

Implementation

Do it as a flex overlay rather than by fighting the transform:

  • Overlay: fixed inset-0 flex justify-center overflow-y-auto
  • Content: fixed top margin (mt-[10dvh], mt-[5dvh] below md), mb-auto, and drop top-[50%] translate-y-[-50%]

Radix's open animation is zoom-in-95, a scale, so it survives losing the translate. translate-x-[-50%] goes too, since the flex container handles horizontal centring.

This is an edit to ui/dialog.tsx, which AGENTS.md permits where the primitive is the natural home for the change. It is, since the positioning is the primitive's.

Trade-off

A short dialog anchored high leaves visible empty space below it, which some people read as stranded. Our confirm, donation and auth-status modals are the short ones.

The alternative is my-auto instead of a fixed top margin, which centres a short dialog and pins a tall one, and is exactly what native <dialog> does. But it reintroduces the inconsistency this issue exists to remove: the top edge would still land somewhere different per dialog.

Recommendation: take the fixed offset and the consistency. If the short dialogs look wrong in review, my-auto is a one-word fallback.

Checks

  • Every modal at phone and desktop width, particularly the four Postavke tabs, since inconsistent height across tabs is the clearest case.
  • Confirm, donation and auth-status, the short ones, and the ones that take the no-children branch where the header is the scroller (modal-shell.tsx:145).
  • With the keyboard open on a form modal.
  • The MultiSelect inline collapsible expanding below md.
  • docs/MOBILE-NAV.md documents the modal flex model at :944; it will need a line about positioning.

Related

  • Overflowing modal bodies give no sign there is more content below #165, the scroll affordance in overflowing modal bodies. Same shell, and both touch the height cap, so they may be worth doing in one pass.
  • modal-shell.tsx:22 TODO(responsive-drawer): if ModalShell eventually renders a vaul drawer below md, the mobile half of this becomes moot. Not a blocker; the desktop half stands either way.

Sources: Bootstrap Modal, Ant Design Modal, Blurry HTML with transform: translate(), CSS-Tricks on the dialog element, csswg-drafts #4645 dialog positioning.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions