Delete your z-index scale
---
title: "Delete your z-index scale"
published: true
description: "A menu cut off by its card or buried under a sticky header is a stacking bug the browser can now fix for you, with no portal and no z-index scale."
tags: react, css, webdev, a11y
canonical_url: box-kite.dev
cover_image: box-kite.dev
series: Building with Box Kite
---
A menu cut off by its card or buried under a sticky header is a stacking bug the browser can now fix for you, with no portal and no z-index scale. Here is the fix, what we measured in Chrome, and where it still falls short.
_The examples here are screenshots; they run live in the original article._
The problem
Your card has overflow: hidden, so its menu is clipped at the edge. You make the menu position: fixed, and then the card gets a hover transform, which makes the card the menu's containing block again. The page's sticky header covers it, and z-index: 9999 does nothing, because the transform created a stacking context the 9999 can't leave.
The CSS issue asking for a way out of overflow: hidden has been open since 2019. Most React apps settled on a portal to <body> plus a scale:
:root {
--z-dropdown: 1000;
--z-modal: 1050;
--z-popover: 1070;
--z-tooltip: 1080;
--z-toast: 1090;
}
The portal works, but the layer leaves the subtree it was declared in:
- A dark section's theme, a custom property or a dir stops reaching it.
- Tab from the trigger skips the layer, which now sits at the end of <body>.
- A dropdown inside a popover is portalled out of it, so picking an option counts as a press outside and closes the popover.
- React events still bubble through the React tree, which React's own issue has tracked since 2017.
The fix: the top layer and anchor positioning
An element with the popover attribute is shown in the browser's top layer. It paints above every stacking context, ignores every ancestor's overflow, and isn't positioned by a transformed ancestor. z-index doesn't apply there: the last layer opened is on top. Anchor positioning then places it beside its trigger in CSS and flips it when there's no room.
The important part is that the layer stays where you declared it in the DOM, so every portal cost above disappears. Support: the Popover API is in Chrome 114, Firefox 125 and Safari 17. Anchor positioning is in Chrome 125, Firefox 147 and Safari 26.
In Box Kite that is <Popover>. The card below clips its content, and a sibling sits on top of it with z-index: 9999:
!The Filters panel opens past the card's clipped edge and paints over a sibling with z-index 9999.
import Box from '@box-kite/react';
import Button from '@box-kite/react/components/button';
import Checkbox from '@box-kite/react/components/checkbox';
import Flex from '@box-kite/react/components/flex';
import Popover from '@box-kite/react/components/popover';
export function FilterCard() {
return (
<Box>
<Box overflow="hidden" translateY={-1} p={4} b={1} borderColor="base-300" borderRadius={3}>
<Popover label="Filters" align="start" trigger={(trigger) => <Button {...trigger}>Filters</Button>}>
<Flex d="column" gap={2}>
<Checkbox label="Only mine" />
<Checkbox label="Has attachments" />
</Flex>
</Popover>
</Box>
<Box position="relative" css={{ zIndex: 9999 }} mt={2} p={3} borderRadius={2} bgColor="red-500" color="white" textAlign="end">
A sibling with z-index: 9999
</Box>
</Box>
);
}
No portal, no z-index. The panel is a named role="dialog", and the trigger carries aria-expanded and aria-controls. Focus moves into the panel, Escape closes it and returns focus to the trigger, and a press outside closes it too.
Every layer works this way
Tooltip, Menu, the Dropdown popup, Dialog, the toasts, the date pickers and the DataGrid's column menu are all in the top layer, and none of them is portalled. Here they are in a dark, clipped, transformed toolbar on a light page, with the dropdown open inside the Filters popover:
!A dark toolbar with a Filters popover open and an Owner dropdown open inside it, both escaping the clipped toolbar.
import Box from '@box-kite/react';
import Button from '@box-kite/react/components/button';
import Dropdown from '@box-kite/react/components/dropdown';
import Flex from '@box-kite/react/components/flex';
import Menu from '@box-kite/react/components/menu';
import Popover from '@box-kite/react/components/popover';
import Tooltip from '@box-kite/react/components/tooltip';
export function Toolbar() {
return (
<Box.Theme use="local" theme="dark">
<Flex gap={2} p={4} borderRadius={3} overflow="hidden" translateY={-1} bgColor="slate-900">
<Popover label="Filters" align="start" trigger={(trigger) => <Button {...trigger}>Filters</Button>}>
<Dropdown label="Owner" defaultValue="ada">
<Dropdown.Item value="ada">Ada</Dropdown.Item>
<Dropdown.Item value="grace">Grace</Dropdown.Item>
</Dropdown>
</Popover>
<Tooltip content="Deletes the row for good">{(trigger) => <Button {...trigger}>Delete</Button>}</Tooltip>
<Menu trigger={(trigger) => <Button {...trigger}>Actions</Button>}>
<Menu.Item>Duplicate</Menu.Item>
<Menu.Item>Archive</Menu.Item>
</Menu>
</Flex>
</Box.Theme>
);
}
The panels are dark because they inherit the section's theme; nothing was copied across. Picking an owner leaves the Filters panel open, because the dropdown's list is still a DOM child of the panel.
Writing your own layer
The same thing by hand is a handful of props. One line is a trap: the browser hides a closed popover with a rule any author display outranks, and every Box sets display. Without not={{ open: { display: 'none' } }}, the closed panel stays on the page.
import Box from '@box-kite/react';
import Button from '@box-kite/react/components/button';
export function RowActions() {
return (
<>
<Button anchorName="row-actions" props={{ popoverTarget: 'row-actions-panel' }}>
Actions
</Button>
<Box
id="row-actions-panel"
position="fixed"
positionAnchor="row-actions"
positionArea="block-end span-inline-end"
positionTryFallbacks="flip-block"
mt={2}
p={4}
not={{ open: { display: 'none' } }}
props={{ popover: 'auto' }}
>
Anything at all
</Box>
</>
);
}
That gives you the paint, the placement and light dismiss, but no role, name or focus handling. Overlay and useAnchorPosition cover the placement for a layer that isn't one of the patterns above.
The honest limits
All measured in Chrome 155:
- On top means on top of everything. Once its trigger scrolls under a sticky header, an open panel still paints above the header.
- An open layer doesn't re-flip on scroll. Chrome never re-runs position-try-fallbacks for a top-layer element. A panel opened below its trigger stayed below as we scrolled, and ran off the bottom of the window. Reopening it picks the right side.
- A toast fired from inside a modal lands under it. It's dimmed, can't be pressed or focused, and is missing from the accessibility tree. Part of that is the platform: everything outside an open modal is inert, even a popover above it. We found this writing the article, and the fix is on the roadmap. Until then, show the result inside the dialog, or toast after it closes.
- Older browsers get the old trade-offs. Without anchor positioning the layer is placed by measuring in JavaScript. Without the Popover API it falls back to a portal.
- Never declare a layer inside its trigger. A menu inside a <button> can't be reached with the keyboard. Put it beside the trigger.
Try it
- Popover, Menu and Dialog: the patterns and their keyboard maps.
- Anchor positioning: the props and the hook.
- npm install @box-kite/react, then delete one portal and one z-index and see what still needs them.
---
_Originally published on box-kite.dev._ Box Kite is open source under MIT — if it is useful to you, a star on GitHub helps other people find it. Install it with npm install @box-kite/react.
Mentions
today is a sad day for the web, and for a11y. but on the bright side, hopefully this is the last time i'll have to explain why buttons and controls should not have `cursor: pointer` alas, it is tiring trying to help you dorks. csswg-drafts#14251
you are all so fucking dumb 🤦♂️ csswg-drafts#14251
buttons, selects and checkboxes are getting the hand cursor by default. interesting 🎭 csswg-drafts#14251 t.co
The debate is over, and evil has won. `cursor: pointer` now means clickable. RIP my ability to tell whether ⌘-clicking something will open it in a new window. csswg-drafts#14251
Cursors for base appearance. CSSWG settled the long-standing debate: form controls with appearance: base get cursor: pointer when clickable, grab on range sliders, and not-allowed when disabled. #css #html csswg-drafts#14251
transform-inverse() and transform-transpose() I propose a transform-inverse and transform-transpose function to be added to CSS. It could greatly reduce the complexity of some 3D scenes, like ones with billboards, and would allow correct interpolation in animations
I see you all writing `position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline`. How about just writing `position-try-fallbacks: flip-any`?
CSSWG just resolved to work on solving the shrink-to-fit feature! csswg-drafts#14551 This is one of the long-standing problems that did not have any non-js solution. Although, this January I published a very hacky technique for solving it […]
Delete your z-index scale --- title: "Delete your z-index scale" published: true description: "A menu cut off by its card or buried under a sticky header is a stacking bug the browser can now fix for you, with no portal and no z-index scale." tags: react, css, webdev, a11y canonical_url: box-kite.dev cover_image: box-kite.dev series: Building with Box Kite --- A menu cut off by its card or buried under a sticky header is a stacking bug the browser can now fix for you, with no portal and no z-index scale. Here is the fix, what we measured in Chrome, and where it still falls short. _The examples here are screenshots; they run live in the original article._ The problem Your card has overflow: hidden, so its menu is clipped at the edge. You make the menu position: fixed, and then the card gets a hover transform, which makes the card the menu's containing block again. The page's sticky header covers it, and z-index: 9999 does nothing, because the transform created a stacking context the 9999 can't leave. The CSS issue asking for a way out of overflow: hidden has been open since 2019. Most React apps settled on a portal to <body> plus a scale: :root { --z-dropdown: 1000; --z-modal: 1050; --z-popover: 1070; --z-tooltip: 1080; --z-toast: 1090; } The portal works, but the layer leaves the subtree it was declared in: - A dark section's theme, a custom property or a dir stops reaching it. - Tab from the trigger skips the layer, which now sits at the end of <body>. - A dropdown inside a popover is portalled out of it, so picking an option counts as a press outside and closes the popover. - React events still bubble through the React tree, which React's own issue has tracked since 2017. The fix: the top layer and anchor positioning An element with the popover attribute is shown in the browser's top layer. It paints above every stacking context, ignores every ancestor's overflow, and isn't positioned by a transformed ancestor. z-index doesn't apply there: the last layer opened is on top. Anchor positioning then places it beside its trigger in CSS and flips it when there's no room. The important part is that the layer stays where you declared it in the DOM, so every portal cost above disappears. Support: the Popover API is in Chrome 114, Firefox 125 and Safari 17. Anchor positioning is in Chrome 125, Firefox 147 and Safari 26. In Box Kite that is <Popover>. The card below clips its content, and a sibling sits on top of it with z-index: 9999: !The Filters panel opens past the card's clipped edge and paints over a sibling with z-index 9999. import Box from '@box-kite/react'; import Button from '@box-kite/react/components/button'; import Checkbox from '@box-kite/react/components/checkbox'; import Flex from '@box-kite/react/components/flex'; import Popover from '@box-kite/react/components/popover'; export function FilterCard() { return ( <Box> <Box overflow="hidden" translateY={-1} p={4} b={1} borderColor="base-300" borderRadius={3}> <Popover label="Filters" align="start" trigger={(trigger) => <Button {...trigger}>Filters</Button>}> <Flex d="column" gap={2}> <Checkbox label="Only mine" /> <Checkbox label="Has attachments" /> </Flex> </Popover> </Box> <Box position="relative" css={{ zIndex: 9999 }} mt={2} p={3} borderRadius={2} bgColor="red-500" color="white" textAlign="end"> A sibling with z-index: 9999 </Box> </Box> ); } No portal, no z-index. The panel is a named role="dialog", and the trigger carries aria-expanded and aria-controls. Focus moves into the panel, Escape closes it and returns focus to the trigger, and a press outside closes it too. Every layer works this way Tooltip, Menu, the Dropdown popup, Dialog, the toasts, the date pickers and the DataGrid's column menu are all in the top layer, and none of them is portalled. Here they are in a dark, clipped, transformed toolbar on a light page, with the dropdown open inside the Filters popover: !A dark toolbar with a Filters popover open and an Owner dropdown open inside it, both escaping the clipped toolbar. import Box from '@box-kite/react'; import Button from '@box-kite/react/components/button'; import Dropdown from '@box-kite/react/components/dropdown'; import Flex from '@box-kite/react/components/flex'; import Menu from '@box-kite/react/components/menu'; import Popover from '@box-kite/react/components/popover'; import Tooltip from '@box-kite/react/components/tooltip'; export function Toolbar() { return ( <Box.Theme use="local" theme="dark"> <Flex gap={2} p={4} borderRadius={3} overflow="hidden" translateY={-1} bgColor="slate-900"> <Popover label="Filters" align="start" trigger={(trigger) => <Button {...trigger}>Filters</Button>}> <Dropdown label="Owner" defaultValue="ada"> <Dropdown.Item value="ada">Ada</Dropdown.Item> <Dropdown.Item value="grace">Grace</Dropdown.Item> </Dropdown> </Popover> <Tooltip content="Deletes the row for good">{(trigger) => <Button {...trigger}>Delete</Button>}</Tooltip> <Menu trigger={(trigger) => <Button {...trigger}>Actions</Button>}> <Menu.Item>Duplicate</Menu.Item> <Menu.Item>Archive</Menu.Item> </Menu> </Flex> </Box.Theme> ); } The panels are dark because they inherit the section's theme; nothing was copied across. Picking an owner leaves the Filters panel open, because the dropdown's list is still a DOM child of the panel. Writing your own layer The same thing by hand is a handful of props. One line is a trap: the browser hides a closed popover with a rule any author display outranks, and every Box sets display. Without not={{ open: { display: 'none' } }}, the closed panel stays on the page. import Box from '@box-kite/react'; import Button from '@box-kite/react/components/button'; export function RowActions() { return ( <> <Button anchorName="row-actions" props={{ popoverTarget: 'row-actions-panel' }}> Actions </Button> <Box id="row-actions-panel" position="fixed" positionAnchor="row-actions" positionArea="block-end span-inline-end" positionTryFallbacks="flip-block" mt={2} p={4} not={{ open: { display: 'none' } }} props={{ popover: 'auto' }} > Anything at all </Box> </> ); } That gives you the paint, the placement and light dismiss, but no role, name or focus handling. Overlay and useAnchorPosition cover the placement for a layer that isn't one of the patterns above. The honest limits All measured in Chrome 155: - On top means on top of everything. Once its trigger scrolls under a sticky header, an open panel still paints above the header. - An open layer doesn't re-flip on scroll. Chrome never re-runs position-try-fallbacks for a top-layer element. A panel opened below its trigger stayed below as we scrolled, and ran off the bottom of the window. Reopening it picks the right side. - A toast fired from inside a modal lands under it. It's dimmed, can't be pressed or focused, and is missing from the accessibility tree. Part of that is the platform: everything outside an open modal is inert, even a popover above it. We found this writing the article, and the fix is on the roadmap. Until then, show the result inside the dialog, or toast after it closes. - Older browsers get the old trade-offs. Without anchor positioning the layer is placed by measuring in JavaScript. Without the Popover API it falls back to a portal. - Never declare a layer inside its trigger. A menu inside a <button> can't be reached with the keyboard. Put it beside the trigger. Try it - Popover, Menu and Dialog: the patterns and their keyboard maps. - Anchor positioning: the props and the hook. - npm install @box-kite/react, then delete one portal and one z-index and see what still needs them. --- _Originally published on box-kite.dev._ Box Kite is open source under MIT — if it is useful to you, a star on GitHub helps other people find it. Install it with npm install @box-kite/react.
Yesterday, the CSSWG accepted my proposal for mesh gradients and diffusion curve gradients! Want to make gradients like this natively in CSS? Well, we've started the work on it. It's coming! Here's the Explainer: github.com/w3c/csswg-drafts/blob/main/css-images-…