Skip to content

Drawer: double-translate on close produces horizontal page scrollbar (introduced by #936) #997

@downsDrew

Description

@downsDrew

Package: @accelint/design-toolkit@9.10.0packages/design-toolkit/src/components/drawer/styles.module.css
Severity: Demo-blocking — produces a horizontal page scrollbar in three downstream Next.js consumer apps.
Introduced by: PR #936 ("Animation"), commit 62c5a63d, 2026-04-13.
Related (separate issue): #992 — closed-state grid column doesn't collapse. Same component, distinct bug.

TL;DR

PR #936 added placement-aware transform: translateX/Y(±100%) rules to the .drawer element. The .panel element (a child of .drawer) already had identical placement-aware translate* rules from before. PR #936 didn't notice the duplicate. Now both elements translate 100% independently when the drawer is closed, and the translates compound: the inner panel ends up 200% off-screen.

For a size='large' drawer (--drawer-size-large: 400px), this puts the panel 800px beyond the viewport edge, producing a horizontal page scrollbar of exactly 2 × --drawer-size-large when the drawer is closed.

The bug is dormant when the drawer is open (both transforms become translate(0,0)), which is why it survived release review — the Storybook open-state visuals look fine.

Repro

  1. App with a <DrawerLayout push='left right'> containing a <Drawer placement='right' size='large'>.
  2. Render with the drawer in the closed state (default for our consumers — drawer opens on user action).
  3. Inspect document.documentElement.scrollWidth. Expected: equal to viewport width. Actual: viewport + 2 × --drawer-size-large.

Direct DOM measurements from one of our apps (viewport 2323px wide):

.layout              x=0,    w=2323, scrollWidth=3123  ← +800px overflow
  .drawer            x=2323, w=400,  transform=translateX(400px)   (✗ added by #936)
    .panel           x=2723, w=400,  transform=translateX(400px)   (existed pre-#936)
                                     ↑
                                     panel sits 800px past viewport edge

2 × 400 = 800 is exact, not coincidental — it's the doubled translate of --drawer-size-large.

Diff PR #936 introduced

The .drawer rule was previously a pure layout selector (grid placement only). PR #936 added the placement-aware translate variants:

   .drawer {
     @apply bg-surface-default text-body-m pointer-events-none relative flex flex-col;

+    @variant motion-safe {
+      transition: transform var(--animation-duration-slow)
+        var(--animation-easing-standard);
+    }

     @variant placement-top {
       @apply col-span-full row-start-1 row-end-2;
+      transform: translateY(-100%);
+
+      @variant open {
+        transform: translateY(0);
+      }
       …
     }

     @variant placement-right {
       @apply col-start-3 col-end-4 row-span-full;
+      transform: translateX(100%);
+
+      @variant open {
+        transform: translateX(0);
+      }
       …
     }

     @variant placement-bottom { …same pattern… }
     @variant placement-left  { …same pattern… }
   }

The pre-existing .panel rules already contained equivalent placement-aware translates:

.panel {
  …
  @variant group-placement-right/drawer { transform: translateX(100%); }
  @variant group-placement-left/drawer  { transform: translateX(-100%); }
  @variant group-placement-top/drawer   { transform: translateY(-100%); }
  @variant group-placement-bottom/drawer { transform: translateY(100%); }

  @variant group-open/drawer {
    opacity: 1;
    pointer-events: auto;
    transform: translate(0, 0);
  }
}

.panel is a child of .drawer. CSS transforms on a parent compose with transforms on children. With both translating 100% on close, the panel slides 100% + 100% = 200% off-screen.

Proposed fix

Remove the placement-aware transforms PR #936 added to .drawer. Keep the transition (it's still useful for any width/height/opacity animation we want to layer in). The pre-existing .panel translates already deliver the slide-out animation.

   .drawer {
     @apply bg-surface-default text-body-m pointer-events-none relative flex flex-col;

     @variant motion-safe {
       transition: transform var(--animation-duration-slow)
         var(--animation-easing-standard);
     }

     @variant placement-top {
       @apply col-span-full row-start-1 row-end-2;
-      transform: translateY(-100%);
-
-      @variant open {
-        transform: translateY(0);
-      }
       …
     }

     @variant placement-right { …similar deletion… }
     @variant placement-bottom { …similar deletion… }
     @variant placement-left  { …similar deletion… }
   }

This is the minimum-change fix that retires the horizontal-scroll bug. It does not retire the related (pre-existing) bug filed at #992 — that's a separate issue (closed grid column doesn't collapse, contributing a footer indent). They're independent.

Caveat: overflow: hidden on .drawer clips the menu trigger

Heads-up for whoever picks this up — we tried adding overflow: hidden on the closed .drawer (as part of the proposed fix in #992 to collapse the grid column) and confirmed it retires both bugs (page horizontal scroll = 0). However, it also makes the drawer's menu trigger button disappear:

.menu {
  @apply rounded-large bg-surface-default p-xs gap-xs absolute flex;

  @variant group-placement-right/drawer {
    @apply right-full flex-col rounded-r-none;
  }
}

.menu is position: absolute inside .drawer (which is position: relative), positioned to render outside the drawer to the side via right-full. Adding overflow: hidden to .drawer clips its absolutely-positioned descendants — including the menu trigger button. Result: drawer menu is invisible/inaccessible.

Any fix involving overflow: hidden on .drawer needs to also lift the menu trigger out of the drawer, or apply the clip on a different layer (the layout, the panel, etc.). The translate-removal fix above sidesteps this by not introducing overflow at all.

Suggested validation

  • Storybook story for <Drawer placement='right' size='large'> in closed state (not just default open). Assert document.documentElement.scrollWidth === viewportWidth after render.
  • Storybook story toggling open ↔ closed. Assert no horizontal scrollbar in either state and the menu trigger remains visible/clickable through the cycle.
  • Repeat for all four placements (top / right / bottom / left).
  • Cross-check data-extend / push='left right' consumer wiring — that's the configuration our apps use.

Workaround we're using until upstream lands

App-side overflow-x-hidden on the root layout in each consumer fork:

- <div className='flex h-screen flex-col'>
+ <div className='flex h-screen flex-col overflow-x-hidden'>

This clips horizontal page overflow at the layout root without touching the toolkit. It does not affect the menu trigger (which is positioned inside the viewport, not in the overflow direction). Tooltips/dropdowns/dialogs are unaffected because they portal outside the layout tree.

Versions

  • @accelint/design-toolkit@9.10.0
  • Next.js 16, React 19, Turbopack, pnpm 10 hoisted
  • Three downstream consumer apps reproducing.

Happy to PR the translate-removal diff.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions