feat(ui): replace --bui-bg-popover with layered bg approach

Remove the --bui-bg-popover token and adopt a two-layer background
pattern across overlay components: outer shell uses --bui-bg-app,
inner content uses Box bg="neutral-1".

Components updated: Popover, Tooltip, Menu, Dialog.

Each component gets a local border-radius CSS variable, a Box
content wrapper with neutral-1 background, and updated arrow
fills where applicable.

Story "is open" variants for each component now use a dot-grid
background pattern to detect background transparency issues in
Chromatic snapshots.

Signed-off-by: Johan Persson <johanopersson@gmail.com>
This commit is contained in:
Johan Persson
2026-02-24 12:58:10 +01:00
parent ba3e15fefb
commit b42fcdca2e
19 changed files with 300 additions and 121 deletions
+16
View File
@@ -0,0 +1,16 @@
---
'@backstage/ui': minor
---
**BREAKING**: Removed `--bui-bg-popover` CSS token. Popover, Tooltip, Menu, and Dialog now use `--bui-bg-app` for their outer shell and `Box bg="neutral-1"` for content areas, providing better theme consistency and eliminating a redundant token.
**Migration:**
Replace any usage of `--bui-bg-popover` with `--bui-bg-neutral-1` (for content surfaces) or `--bui-bg-app` (for outer shells):
```diff
- background: var(--bui-bg-popover);
+ background: var(--bui-bg-neutral-1);
```
**Affected components:** Popover, Tooltip, Menu, Dialog
-1
View File
@@ -156,7 +156,6 @@ These colors form a layered neutral scale for your application backgrounds. `--b
| Token Name | Description |
| ----------------------------- | ------------------------------------------------------------ |
| `--bui-bg-app` | The base background color of your Backstage instance. |
| `--bui-bg-popover` | The background color used for popovers, tooltips, and menus. |
| `--bui-bg-neutral-1` | First elevated layer. Use for cards, dialogs, and panels. |
| `--bui-bg-neutral-1-hover` | Hover state for elements on neutral-1. |
| `--bui-bg-neutral-1-pressed` | Pressed state for elements on neutral-1. |
+3
View File
@@ -897,6 +897,7 @@ export const DialogDefinition: {
readonly classNames: {
readonly overlay: 'bui-DialogOverlay';
readonly dialog: 'bui-Dialog';
readonly content: 'bui-DialogContent';
readonly header: 'bui-DialogHeader';
readonly headerTitle: 'bui-DialogHeaderTitle';
readonly body: 'bui-DialogBody';
@@ -1331,6 +1332,7 @@ export const MenuDefinition: {
readonly classNames: {
readonly root: 'bui-Menu';
readonly popover: 'bui-MenuPopover';
readonly inner: 'bui-MenuInner';
readonly content: 'bui-MenuContent';
readonly section: 'bui-MenuSection';
readonly sectionHeader: 'bui-MenuSectionHeader';
@@ -2218,6 +2220,7 @@ export const Tooltip: ForwardRefExoticComponent<
export const TooltipDefinition: {
readonly classNames: {
readonly tooltip: 'bui-Tooltip';
readonly content: 'bui-TooltipContent';
readonly arrow: 'bui-TooltipArrow';
};
};
@@ -43,8 +43,10 @@
}
.bui-Dialog {
background: var(--bui-bg-popover);
border-radius: 0.5rem;
--dialog-border-radius: 0.5rem;
background: var(--bui-bg-app);
box-shadow: var(--bui-shadow);
border-radius: var(--dialog-border-radius);
border: 1px solid var(--bui-border-1);
color: var(--bui-fg-primary);
position: relative;
@@ -52,9 +54,14 @@
max-width: calc(100vw - 3rem);
height: min(var(--bui-dialog-min-height, auto), calc(100vh - 3rem));
max-height: calc(100vh - 3rem);
outline: none;
}
.bui-DialogContent {
display: flex;
flex-direction: column;
outline: none;
border-radius: var(--dialog-border-radius);
height: 100%;
}
/* Dialog entering animation */
@@ -63,6 +63,24 @@ export const Default = meta.story({
});
export const Open = Default.extend({
parameters: { layout: 'fullscreen' },
decorators: [
Story => (
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundImage:
'radial-gradient(circle, var(--bui-border-1) 1px, transparent 1px)',
backgroundSize: '16px 16px',
}}
>
<Story />
</div>
),
],
args: {
defaultOpen: true,
},
+7 -1
View File
@@ -33,6 +33,7 @@ import { Button } from '../Button';
import { useStyles } from '../../hooks/useStyles';
import { DialogDefinition } from './definition';
import { Flex } from '../Flex';
import { Box } from '../Box';
import styles from './Dialog.module.css';
/** @public */
@@ -71,7 +72,12 @@ export const Dialog = forwardRef<React.ElementRef<typeof Modal>, DialogProps>(
...style,
}}
>
{children}
<Box
bg="neutral-1"
className={clsx(classNames.content, styles[classNames.content])}
>
{children}
</Box>
</RADialog>
</Modal>
);
@@ -24,6 +24,7 @@ export const DialogDefinition = {
classNames: {
overlay: 'bui-DialogOverlay',
dialog: 'bui-Dialog',
content: 'bui-DialogContent',
header: 'bui-DialogHeader',
headerTitle: 'bui-DialogHeaderTitle',
body: 'bui-DialogBody',
@@ -18,12 +18,13 @@
@layer components {
.bui-MenuPopover {
--menu-border-radius: var(--bui-radius-2);
display: flex;
flex-direction: column;
box-shadow: var(--bui-shadow);
border: 1px solid var(--bui-border-1);
border-radius: var(--bui-radius-2);
background: var(--bui-bg-popover);
border-radius: var(--menu-border-radius);
background: var(--bui-bg-app);
color: var(--bui-fg-primary);
outline: none;
transition: transform 200ms, opacity 200ms;
@@ -55,6 +56,10 @@
}
}
.bui-MenuInner {
border-radius: var(--menu-border-radius);
}
.bui-MenuContent {
max-height: inherit;
box-sizing: border-box;
@@ -198,6 +198,24 @@ export const PreviewLinks = meta.story({
});
export const Opened = meta.story({
parameters: { layout: 'fullscreen' },
decorators: [
Story => (
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundImage:
'radial-gradient(circle, var(--bui-border-1) 1px, transparent 1px)',
backgroundSize: '16px 16px',
}}
>
<Story />
</div>
),
],
args: {
...Preview.input.args,
},
+117 -96
View File
@@ -58,6 +58,7 @@ import {
} from '../InternalLinkProvider';
import styles from './Menu.module.css';
import clsx from 'clsx';
import { Box } from '../Box';
const { RoutingProvider, useRoutingRegistrationEffect } =
createRoutingRegistration();
@@ -119,18 +120,23 @@ export const Menu = (props: MenuProps<object>) => {
)}
placement={placement}
>
{virtualized ? (
<Virtualizer
layout={ListLayout}
layoutOptions={{
rowHeight,
}}
>
{menuContent}
</Virtualizer>
) : (
menuContent
)}
<Box
bg="neutral-1"
className={clsx(classNames.inner, styles[classNames.inner])}
>
{virtualized ? (
<Virtualizer
layout={ListLayout}
layoutOptions={{
rowHeight,
}}
>
{menuContent}
</Virtualizer>
) : (
menuContent
)}
</Box>
</RAPopover>
</RoutingProvider>
);
@@ -169,18 +175,23 @@ export const MenuListBox = (props: MenuListBoxProps<object>) => {
)}
placement={placement}
>
{virtualized ? (
<Virtualizer
layout={ListLayout}
layoutOptions={{
rowHeight,
}}
>
{listBoxContent}
</Virtualizer>
) : (
listBoxContent
)}
<Box
bg="neutral-1"
className={clsx(classNames.inner, styles[classNames.inner])}
>
{virtualized ? (
<Virtualizer
layout={ListLayout}
layoutOptions={{
rowHeight,
}}
>
{listBoxContent}
</Virtualizer>
) : (
listBoxContent
)}
</Box>
</RAPopover>
);
};
@@ -219,43 +230,48 @@ export const MenuAutocomplete = (props: MenuAutocompleteProps<object>) => {
)}
placement={placement}
>
<RAAutocomplete filter={contains}>
<RASearchField
className={clsx(
classNames.searchField,
styles[classNames.searchField],
<Box
bg="neutral-1"
className={clsx(classNames.inner, styles[classNames.inner])}
>
<RAAutocomplete filter={contains}>
<RASearchField
className={clsx(
classNames.searchField,
styles[classNames.searchField],
)}
aria-label={props.placeholder || 'Search'}
>
<RAInput
className={clsx(
classNames.searchFieldInput,
styles[classNames.searchFieldInput],
)}
placeholder={props.placeholder || 'Search...'}
/>
<RAButton
className={clsx(
classNames.searchFieldClear,
styles[classNames.searchFieldClear],
)}
>
<RiCloseCircleLine />
</RAButton>
</RASearchField>
{virtualized ? (
<Virtualizer
layout={ListLayout}
layoutOptions={{
rowHeight,
}}
>
{menuContent}
</Virtualizer>
) : (
menuContent
)}
aria-label={props.placeholder || 'Search'}
>
<RAInput
className={clsx(
classNames.searchFieldInput,
styles[classNames.searchFieldInput],
)}
placeholder={props.placeholder || 'Search...'}
/>
<RAButton
className={clsx(
classNames.searchFieldClear,
styles[classNames.searchFieldClear],
)}
>
<RiCloseCircleLine />
</RAButton>
</RASearchField>
{virtualized ? (
<Virtualizer
layout={ListLayout}
layoutOptions={{
rowHeight,
}}
>
{menuContent}
</Virtualizer>
) : (
menuContent
)}
</RAAutocomplete>
</RAAutocomplete>
</Box>
</RAPopover>
</RoutingProvider>
);
@@ -298,43 +314,48 @@ export const MenuAutocompleteListbox = (
)}
placement={placement}
>
<RAAutocomplete filter={contains}>
<RASearchField
className={clsx(
classNames.searchField,
styles[classNames.searchField],
<Box
bg="neutral-1"
className={clsx(classNames.inner, styles[classNames.inner])}
>
<RAAutocomplete filter={contains}>
<RASearchField
className={clsx(
classNames.searchField,
styles[classNames.searchField],
)}
aria-label={props.placeholder || 'Search'}
>
<RAInput
className={clsx(
classNames.searchFieldInput,
styles[classNames.searchFieldInput],
)}
placeholder={props.placeholder || 'Search...'}
/>
<RAButton
className={clsx(
classNames.searchFieldClear,
styles[classNames.searchFieldClear],
)}
>
<RiCloseCircleLine />
</RAButton>
</RASearchField>
{virtualized ? (
<Virtualizer
layout={ListLayout}
layoutOptions={{
rowHeight,
}}
>
{listBoxContent}
</Virtualizer>
) : (
listBoxContent
)}
aria-label={props.placeholder || 'Search'}
>
<RAInput
className={clsx(
classNames.searchFieldInput,
styles[classNames.searchFieldInput],
)}
placeholder={props.placeholder || 'Search...'}
/>
<RAButton
className={clsx(
classNames.searchFieldClear,
styles[classNames.searchFieldClear],
)}
>
<RiCloseCircleLine />
</RAButton>
</RASearchField>
{virtualized ? (
<Virtualizer
layout={ListLayout}
layoutOptions={{
rowHeight,
}}
>
{listBoxContent}
</Virtualizer>
) : (
listBoxContent
)}
</RAAutocomplete>
</RAAutocomplete>
</Box>
</RAPopover>
);
};
@@ -24,6 +24,7 @@ export const MenuDefinition = {
classNames: {
root: 'bui-Menu',
popover: 'bui-MenuPopover',
inner: 'bui-MenuInner',
content: 'bui-MenuContent',
section: 'bui-MenuSection',
sectionHeader: 'bui-MenuSectionHeader',
@@ -18,9 +18,10 @@
@layer components {
.bui-Popover {
--popover-border-radius: var(--bui-radius-3);
box-shadow: var(--bui-shadow);
border-radius: var(--bui-radius-3);
background: var(--bui-bg-popover);
border-radius: var(--popover-border-radius);
background: var(--bui-bg-app);
border: 1px solid var(--bui-border-1);
forced-color-adjust: none;
outline: none;
@@ -76,6 +77,7 @@
padding: var(--bui-space-4);
flex: 1 1 auto;
min-height: 0;
border-radius: var(--popover-border-radius);
}
.bui-PopoverArrow {
@@ -89,11 +91,14 @@
we split the stroke and fill across separate
elements in order to guarantee that the stroke is
always overlaying a consistent color. */
path:nth-child(1) {
fill: var(--bui-bg-popover);
use:nth-of-type(1) {
fill: var(--bui-bg-app);
}
use:nth-of-type(2) {
fill: var(--bui-bg-neutral-1);
}
path:nth-child(2) {
path {
fill: var(--bui-border-1);
}
@@ -19,6 +19,7 @@ import { Button } from '../Button/Button';
import { DialogTrigger } from '../Dialog/Dialog';
import { Text } from '../Text/Text';
import { Flex } from '../Flex/Flex';
import { Box } from '../Box';
const meta = preview.meta({
title: 'Backstage UI/Popover',
@@ -74,6 +75,24 @@ export const Default = meta.story({
});
export const IsOpen = Default.extend({
parameters: { layout: 'fullscreen' },
decorators: [
Story => (
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundImage:
'radial-gradient(circle, var(--bui-border-1) 1px, transparent 1px)',
backgroundSize: '16px 16px',
}}
>
<Story />
</div>
),
],
args: {
isOpen: true,
},
@@ -189,6 +208,9 @@ export const WithRichContent = Default.extend({
This is a popover with rich content. It can contain multiple
elements and formatted text.
</Text>
<Box bg="neutral-auto" p="2">
<Text>You can also use the automatic bg system inside it.</Text>
</Box>
<Flex gap="2" justify="end">
<Button variant="tertiary" size="small">
Cancel
+17 -3
View File
@@ -15,12 +15,14 @@
*/
import { forwardRef } from 'react';
import { useId } from 'react-aria';
import { OverlayArrow, Popover as AriaPopover } from 'react-aria-components';
import clsx from 'clsx';
import { PopoverProps } from './types';
import { useStyles } from '../../hooks/useStyles';
import { PopoverDefinition } from './definition';
import styles from './Popover.module.css';
import { Box } from '../Box';
/**
* A popover component built on React Aria Components that displays floating
@@ -61,6 +63,7 @@ export const Popover = forwardRef<HTMLDivElement, PopoverProps>(
(props, ref) => {
const { classNames, cleanedProps } = useStyles(PopoverDefinition, props);
const { className, children, hideArrow, ...rest } = cleanedProps;
const svgPathId = useId();
return (
<AriaPopover
@@ -77,16 +80,27 @@ export const Popover = forwardRef<HTMLDivElement, PopoverProps>(
className={clsx(classNames.arrow, styles[classNames.arrow])}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M10.3356 7.39793L15.1924 3.02682C15.9269 2.36577 16.8801 2 17.8683 2H20V7.94781e-07L1.74846e-07 -9.53674e-07L0 2L1.4651 2C2.4532 2 3.4064 2.36577 4.1409 3.02682L8.9977 7.39793C9.378 7.7402 9.9553 7.74021 10.3356 7.39793Z" />
<defs>
<path
id={svgPathId}
fillRule="evenodd"
d="M10.3356 7.39793L15.1924 3.02682C15.9269 2.36577 16.8801 2 17.8683 2H20V7.94781e-07L1.74846e-07 -9.53674e-07L0 2L1.4651 2C2.4532 2 3.4064 2.36577 4.1409 3.02682L8.9977 7.39793C9.378 7.7402 9.9553 7.74021 10.3356 7.39793Z M11.0046 8.14124C10.2439 8.82575 9.08939 8.82578 8.32869 8.14122L3.47189 3.77011C2.92109 3.27432 2.20619 2.99999 1.46509 2.99999L4.10999 3L8.99769 7.39793C9.37799 7.7402 9.95529 7.7402 10.3356 7.39793L15.2226 3L17.8683 2.99999C17.1271 2.99999 16.4122 3.27432 15.8614 3.77011L11.0046 8.14124Z"
/>
</defs>
<use href={`#${svgPathId}`} />
<use href={`#${svgPathId}`} />
<path d="M11.0046 8.14124C10.2439 8.82575 9.08939 8.82578 8.32869 8.14122L3.47189 3.77011C2.92109 3.27432 2.20619 2.99999 1.46509 2.99999L4.10999 3L8.99769 7.39793C9.37799 7.7402 9.95529 7.7402 10.3356 7.39793L15.2226 3L17.8683 2.99999C17.1271 2.99999 16.4122 3.27432 15.8614 3.77011L11.0046 8.14124Z" />
</svg>
</OverlayArrow>
)}
<div
<Box
bg="neutral-1"
className={clsx(classNames.content, styles[classNames.content])}
>
{children}
</div>
</Box>
</>
)}
</AriaPopover>
@@ -18,13 +18,13 @@
@layer components {
.bui-Tooltip {
--tooltip-border-radius: 4px;
box-shadow: var(--bui-shadow);
border-radius: 4px;
background: var(--bui-bg-popover);
border-radius: var(--tooltip-border-radius);
background: var(--bui-bg-app);
border: 1px solid var(--bui-border-1);
forced-color-adjust: none;
outline: none;
padding: var(--bui-space-2) var(--bui-space-3);
max-width: 240px;
/* fixes FF gap */
transform: translate3d(0, 0, 0);
@@ -62,6 +62,11 @@
}
}
.bui-TooltipContent {
padding: var(--bui-space-2) var(--bui-space-3);
border-radius: var(--tooltip-border-radius);
}
.bui-TooltipArrow {
& svg {
display: block;
@@ -73,11 +78,14 @@
we split the stroke and fill across separate
elements in order to guarantee that the stroke is
always overlaying a consistent color. */
path:nth-child(1) {
fill: var(--bui-bg-popover);
use:nth-of-type(1) {
fill: var(--bui-bg-app);
}
use:nth-of-type(2) {
fill: var(--bui-bg-neutral-1);
}
path:nth-child(2) {
path {
fill: var(--bui-border-1);
}
@@ -55,6 +55,24 @@ export const Default = meta.story({
});
export const IsOpen = meta.story({
parameters: { layout: 'fullscreen' },
decorators: [
Story => (
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundImage:
'radial-gradient(circle, var(--bui-border-1) 1px, transparent 1px)',
backgroundSize: '16px 16px',
}}
>
<Story />
</div>
),
],
args: {
...Default.input.args,
isOpen: true,
+20 -2
View File
@@ -15,6 +15,7 @@
*/
import { forwardRef } from 'react';
import { useId } from 'react-aria';
import {
OverlayArrow,
Tooltip as AriaTooltip,
@@ -26,6 +27,7 @@ import { TooltipProps } from './types';
import { useStyles } from '../../hooks/useStyles';
import { TooltipDefinition } from './definition';
import styles from './Tooltip.module.css';
import { Box } from '../Box';
/** @public */
export const TooltipTrigger = (props: TooltipTriggerComponentProps) => {
@@ -39,6 +41,7 @@ export const Tooltip = forwardRef<HTMLDivElement, TooltipProps>(
(props, ref) => {
const { classNames, cleanedProps } = useStyles(TooltipDefinition, props);
const { className, children, ...rest } = cleanedProps;
const svgPathId = useId();
return (
<AriaTooltip
@@ -54,11 +57,26 @@ export const Tooltip = forwardRef<HTMLDivElement, TooltipProps>(
className={clsx(classNames.arrow, styles[classNames.arrow])}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M10.3356 7.39793L15.1924 3.02682C15.9269 2.36577 16.8801 2 17.8683 2H20V7.94781e-07L1.74846e-07 -9.53674e-07L0 2L1.4651 2C2.4532 2 3.4064 2.36577 4.1409 3.02682L8.9977 7.39793C9.378 7.7402 9.9553 7.74021 10.3356 7.39793Z" />
<defs>
<path
id={svgPathId}
fillRule="evenodd"
d="M10.3356 7.39793L15.1924 3.02682C15.9269 2.36577 16.8801 2 17.8683 2H20V7.94781e-07L1.74846e-07 -9.53674e-07L0 2L1.4651 2C2.4532 2 3.4064 2.36577 4.1409 3.02682L8.9977 7.39793C9.378 7.7402 9.9553 7.74021 10.3356 7.39793Z M11.0046 8.14124C10.2439 8.82575 9.08939 8.82578 8.32869 8.14122L3.47189 3.77011C2.92109 3.27432 2.20619 2.99999 1.46509 2.99999L4.10999 3L8.99769 7.39793C9.37799 7.7402 9.95529 7.7402 10.3356 7.39793L15.2226 3L17.8683 2.99999C17.1271 2.99999 16.4122 3.27432 15.8614 3.77011L11.0046 8.14124Z"
/>
</defs>
<use href={`#${svgPathId}`} />
<use href={`#${svgPathId}`} />
<path d="M11.0046 8.14124C10.2439 8.82575 9.08939 8.82578 8.32869 8.14122L3.47189 3.77011C2.92109 3.27432 2.20619 2.99999 1.46509 2.99999L4.10999 3L8.99769 7.39793C9.37799 7.7402 9.95529 7.7402 10.3356 7.39793L15.2226 3L17.8683 2.99999C17.1271 2.99999 16.4122 3.27432 15.8614 3.77011L11.0046 8.14124Z" />
</svg>
</OverlayArrow>
{children}
<Box
bg="neutral-1"
className={clsx(classNames.content, styles[classNames.content])}
>
{children}
</Box>
</AriaTooltip>
);
},
@@ -23,6 +23,7 @@ import type { ComponentDefinition } from '../../types';
export const TooltipDefinition = {
classNames: {
tooltip: 'bui-Tooltip',
content: 'bui-TooltipContent',
arrow: 'bui-TooltipArrow',
},
} as const satisfies ComponentDefinition;
-2
View File
@@ -78,7 +78,6 @@
/* Neutral background colors */
--bui-bg-app: #f8f8f8;
--bui-bg-popover: #ffffff;
--bui-bg-neutral-1: #fff;
--bui-bg-neutral-1-hover: oklch(0% 0 0 / 12%);
@@ -153,7 +152,6 @@
/* Neutral background colors */
--bui-bg-app: #333333;
--bui-bg-popover: #1a1a1a;
--bui-bg-neutral-1: oklch(100% 0 0 / 10%);
--bui-bg-neutral-1-hover: oklch(100% 0 0 / 14%);