diff --git a/.nx/version-plans/version-plan-1783541829799.md b/.nx/version-plans/version-plan-1783541829799.md
new file mode 100644
index 00000000000..a4717424084
--- /dev/null
+++ b/.nx/version-plans/version-plan-1783541829799.md
@@ -0,0 +1,34 @@
+---
+eslint-plugin-gamut: major
+gamut-styles: minor
+gamut: major
+gamut-kit: major
+---
+
+Add a semantic z-index scale (`zIndices`) and migrate Gamut off ad-hoc z-index values.
+
+**gamut-styles**
+
+- Add the `zIndices` token scale (exported object + theme scale with `--zIndices-*` CSS variables):
+ `underlay (-100)`, `base (0)`, `foreground (100)`, `portal (200)`, `widget (300)`, `appBar (400)`,
+ `flyout (500)`, `modal (600)`, `popover (700)`, `toaster (800)`, `tooltip (900)`.
+- It is a _soft_ scale: the `zIndex` prop stays numeric, so tokens are used as `zIndex={zIndices.modal}`
+ and raw-number escape hatches / arithmetic (`zIndices.foreground - 2`) still work.
+- `elements.headerZ` now aliases `zIndices.appBar` (was `15`). Prefer `zIndices.appBar` going forward.
+
+**gamut**
+
+- ⚠️ Behavior change — component stacking now uses the scale, which retunes several z-index values:
+ `Overlay`/`Modal`/`Dialog` → `modal (600)`, `Flyout` → `flyout (500)`, `Toaster` → `toaster (800)`
+ (fixes toasts previously stacking atPortal`default →`portal (200)`, sticky
+`TableHeader`→`foreground (100)`, and many in-flow values → `underlay`/`base`/`foreground`.
+- `SelectDropdown`'s options menu now portals to `document.body` (a true popover at `zIndices.popover`),
+ so it renders above sticky headers and modal content instead of being clipped.
+- No public API is removed or retyped; the `zIndex` prop still accepts numbers.
+
+**eslint-plugin-gamut**
+
+- Add rule `gamut/no-raw-z-index`, enabled as `error` in the recommended config. It flags raw numeric
+ z-index literals in style objects and JSX props and steers to `zIndices` tokens; token references and
+ arithmetic are allowed, and deliberate escape hatches use an inline `eslint-disable`. Major because
+ the new error-level recommended rule can fail existing lint in consumers until raw z-index is migrated.
diff --git a/packages/eslint-plugin-gamut/src/index.tsx b/packages/eslint-plugin-gamut/src/index.tsx
index 9459f37a2a0..f4346fbc28d 100644
--- a/packages/eslint-plugin-gamut/src/index.tsx
+++ b/packages/eslint-plugin-gamut/src/index.tsx
@@ -2,6 +2,7 @@ import gamutImportPaths from './gamut-import-paths';
import noCssStandalone from './no-css-standalone';
import noInlineStyle from './no-inline-style';
import noKbdElement from './no-kbd-element';
+import noRawZIndex from './no-raw-z-index';
import preferThemed from './prefer-themed';
import recommended from './recommended';
@@ -10,6 +11,7 @@ const rules = {
'no-css-standalone': noCssStandalone,
'no-inline-style': noInlineStyle,
'no-kbd-element': noKbdElement,
+ 'no-raw-z-index': noRawZIndex,
'prefer-themed': preferThemed,
};
diff --git a/packages/eslint-plugin-gamut/src/no-raw-z-index.test.ts b/packages/eslint-plugin-gamut/src/no-raw-z-index.test.ts
new file mode 100644
index 00000000000..75da9221e7b
--- /dev/null
+++ b/packages/eslint-plugin-gamut/src/no-raw-z-index.test.ts
@@ -0,0 +1,55 @@
+import { ESLintUtils } from '@typescript-eslint/utils';
+
+import rule from './no-raw-z-index';
+
+const ruleTester = new ESLintUtils.RuleTester({
+ parser: '@typescript-eslint/parser',
+ parserOptions: {
+ ecmaFeatures: {
+ jsx: true,
+ },
+ },
+});
+
+ruleTester.run('no-raw-z-index', rule, {
+ valid: [
+ // Semantic tokens are the expected usage.
+ `const styles = { zIndex: zIndices.modal };`,
+ `;`,
+ // Arithmetic on a token is allowed (e.g. Tip's shadow).
+ `const styles = { zIndex: zIndices.foreground - 2 };`,
+ `;`,
+ // Variables / non-literal expressions are not flagged.
+ `;`,
+ `const styles = { zIndex };`,
+ // Unrelated properties.
+ `const styles = { padding: 0 };`,
+ `;`,
+ ],
+ invalid: [
+ {
+ code: `const styles = { zIndex: 1 };`,
+ errors: [{ messageId: 'noRawZIndex' }],
+ },
+ {
+ code: `const styles = { zIndex: 0 };`,
+ errors: [{ messageId: 'noRawZIndex' }],
+ },
+ {
+ code: `const styles = { zIndex: -1 };`,
+ errors: [{ messageId: 'noRawZIndex' }],
+ },
+ {
+ code: `const styles = { 'z-index': 100 };`,
+ errors: [{ messageId: 'noRawZIndex' }],
+ },
+ {
+ code: `;`,
+ errors: [{ messageId: 'noRawZIndex' }],
+ },
+ {
+ code: `;`,
+ errors: [{ messageId: 'noRawZIndex' }],
+ },
+ ],
+});
diff --git a/packages/eslint-plugin-gamut/src/no-raw-z-index.ts b/packages/eslint-plugin-gamut/src/no-raw-z-index.ts
new file mode 100644
index 00000000000..3a0ca1e55eb
--- /dev/null
+++ b/packages/eslint-plugin-gamut/src/no-raw-z-index.ts
@@ -0,0 +1,62 @@
+import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
+
+import { createRule } from './createRule';
+
+/**
+ * True for a numeric literal, including a negated one like `-1`.
+ */
+const isNumericLiteral = (node: TSESTree.Node | null | undefined): boolean => {
+ if (!node) return false;
+ if (node.type === AST_NODE_TYPES.Literal && typeof node.value === 'number') {
+ return true;
+ }
+ return (
+ node.type === AST_NODE_TYPES.UnaryExpression &&
+ (node.operator === '-' || node.operator === '+') &&
+ isNumericLiteral(node.argument)
+ );
+};
+
+const isZIndexKey = (key: TSESTree.Node): boolean =>
+ (key.type === AST_NODE_TYPES.Identifier && key.name === 'zIndex') ||
+ (key.type === AST_NODE_TYPES.Literal && key.value === 'zIndex') ||
+ (key.type === AST_NODE_TYPES.Literal && key.value === 'z-index');
+
+export default createRule({
+ create(context) {
+ return {
+ // Style objects: `{ zIndex: 1 }` / `{ 'z-index': 1 }`
+ Property(node) {
+ if (isZIndexKey(node.key) && isNumericLiteral(node.value)) {
+ context.report({ messageId: 'noRawZIndex', node: node.value });
+ }
+ },
+ // JSX props: ``
+ JSXAttribute(node) {
+ if (
+ node.name.type === AST_NODE_TYPES.JSXIdentifier &&
+ node.name.name === 'zIndex' &&
+ node.value?.type === AST_NODE_TYPES.JSXExpressionContainer &&
+ isNumericLiteral(node.value.expression as TSESTree.Node)
+ ) {
+ context.report({ messageId: 'noRawZIndex', node: node.value });
+ }
+ },
+ };
+ },
+ defaultOptions: [],
+ meta: {
+ docs: {
+ description:
+ 'Discourage raw numeric z-index values which can lead to z-index stacking issues and encourage usage of semantic tokens from the `zIndices` scale.',
+ recommended: 'error',
+ },
+ messages: {
+ noRawZIndex:
+ 'Semantic tokens from the `zIndices` scale (e.g. `zIndices.modal`) are recommendedinstead of a raw z-index number. For a deliberate in-between value, disable this rule inline with a justifying comment.',
+ },
+ type: 'suggestion',
+ schema: [],
+ },
+ name: 'no-raw-z-index',
+});
diff --git a/packages/eslint-plugin-gamut/src/recommended.ts b/packages/eslint-plugin-gamut/src/recommended.ts
index 970bb7700ff..a1d71b635c9 100644
--- a/packages/eslint-plugin-gamut/src/recommended.ts
+++ b/packages/eslint-plugin-gamut/src/recommended.ts
@@ -2,6 +2,7 @@ export default {
rules: {
'gamut/no-css-standalone': 'error',
'gamut/no-inline-style': 'error',
+ 'gamut/no-raw-z-index': 'error',
'gamut/prefer-themed': 'off',
'gamut/gamut-import-paths': 'error',
},
diff --git a/packages/gamut-styles/src/themes/__tests__/__snapshots__/theme.test.ts.snap b/packages/gamut-styles/src/themes/__tests__/__snapshots__/theme.test.ts.snap
index 2be2c419212..c04ce5490db 100644
--- a/packages/gamut-styles/src/themes/__tests__/__snapshots__/theme.test.ts.snap
+++ b/packages/gamut-styles/src/themes/__tests__/__snapshots__/theme.test.ts.snap
@@ -79,7 +79,7 @@ exports[`themes admin - theme shape 1`] = `
"base": "4rem",
"md": "5rem",
},
- "headerZ": 15,
+ "headerZ": 400,
},
"modes": {
"dark": {
@@ -151,6 +151,19 @@ exports[`themes admin - theme shape 1`] = `
"text-secondary": "rgba(16,22,47,0.75)",
},
},
+ "zIndices": {
+ "appBar": 400,
+ "base": 0,
+ "flyout": 500,
+ "foreground": 100,
+ "modal": 600,
+ "popover": 700,
+ "portal": 200,
+ "toaster": 800,
+ "tooltip": 900,
+ "underlay": -100,
+ "widget": 300,
+ },
},
"_variables": {
"mode": {
@@ -257,7 +270,18 @@ exports[`themes admin - theme shape 1`] = `
"--color-yellow-500": "#FFD300",
"--color-yellow-900": "#211B00",
"--elements-headerHeight": "4rem",
- "--elements-headerZ": 15,
+ "--elements-headerZ": 400,
+ "--zIndices-appBar": 400,
+ "--zIndices-base": 0,
+ "--zIndices-flyout": 500,
+ "--zIndices-foreground": 100,
+ "--zIndices-modal": 600,
+ "--zIndices-popover": 700,
+ "--zIndices-portal": 200,
+ "--zIndices-toaster": 800,
+ "--zIndices-tooltip": 900,
+ "--zIndices-underlay": -100,
+ "--zIndices-widget": 300,
"@media only screen and (min-width: 1024px)": {
"--elements-headerHeight": "5rem",
},
@@ -513,6 +537,19 @@ monospace",
"8": "0.5rem",
"96": "6rem",
},
+ "zIndices": {
+ "appBar": "var(--zIndices-appBar)",
+ "base": "var(--zIndices-base)",
+ "flyout": "var(--zIndices-flyout)",
+ "foreground": "var(--zIndices-foreground)",
+ "modal": "var(--zIndices-modal)",
+ "popover": "var(--zIndices-popover)",
+ "portal": "var(--zIndices-portal)",
+ "toaster": "var(--zIndices-toaster)",
+ "tooltip": "var(--zIndices-tooltip)",
+ "underlay": "var(--zIndices-underlay)",
+ "widget": "var(--zIndices-widget)",
+ },
}
`;
@@ -595,7 +632,7 @@ exports[`themes core - theme shape 1`] = `
"base": "4rem",
"md": "5rem",
},
- "headerZ": 15,
+ "headerZ": 400,
},
"modes": {
"dark": {
@@ -667,6 +704,19 @@ exports[`themes core - theme shape 1`] = `
"text-secondary": "rgba(16,22,47,0.75)",
},
},
+ "zIndices": {
+ "appBar": 400,
+ "base": 0,
+ "flyout": 500,
+ "foreground": 100,
+ "modal": 600,
+ "popover": 700,
+ "portal": 200,
+ "toaster": 800,
+ "tooltip": 900,
+ "underlay": -100,
+ "widget": 300,
+ },
},
"_variables": {
"mode": {
@@ -773,7 +823,18 @@ exports[`themes core - theme shape 1`] = `
"--color-yellow-500": "#FFD300",
"--color-yellow-900": "#211B00",
"--elements-headerHeight": "4rem",
- "--elements-headerZ": 15,
+ "--elements-headerZ": 400,
+ "--zIndices-appBar": 400,
+ "--zIndices-base": 0,
+ "--zIndices-flyout": 500,
+ "--zIndices-foreground": 100,
+ "--zIndices-modal": 600,
+ "--zIndices-popover": 700,
+ "--zIndices-portal": 200,
+ "--zIndices-toaster": 800,
+ "--zIndices-tooltip": 900,
+ "--zIndices-underlay": -100,
+ "--zIndices-widget": 300,
"@media only screen and (min-width: 1024px)": {
"--elements-headerHeight": "5rem",
},
@@ -1029,6 +1090,19 @@ monospace",
"8": "0.5rem",
"96": "6rem",
},
+ "zIndices": {
+ "appBar": "var(--zIndices-appBar)",
+ "base": "var(--zIndices-base)",
+ "flyout": "var(--zIndices-flyout)",
+ "foreground": "var(--zIndices-foreground)",
+ "modal": "var(--zIndices-modal)",
+ "popover": "var(--zIndices-popover)",
+ "portal": "var(--zIndices-portal)",
+ "toaster": "var(--zIndices-toaster)",
+ "tooltip": "var(--zIndices-tooltip)",
+ "underlay": "var(--zIndices-underlay)",
+ "widget": "var(--zIndices-widget)",
+ },
}
`;
@@ -1114,7 +1188,7 @@ exports[`themes lxStudio - theme shape 1`] = `
"base": "4rem",
"md": "5rem",
},
- "headerZ": 15,
+ "headerZ": 400,
},
"modes": {
"dark": {
@@ -1186,6 +1260,19 @@ exports[`themes lxStudio - theme shape 1`] = `
"text-secondary": "rgba(16,22,47,0.75)",
},
},
+ "zIndices": {
+ "appBar": 400,
+ "base": 0,
+ "flyout": 500,
+ "foreground": 100,
+ "modal": 600,
+ "popover": 700,
+ "portal": 200,
+ "toaster": 800,
+ "tooltip": 900,
+ "underlay": -100,
+ "widget": 300,
+ },
},
"_variables": {
"mode": {
@@ -1295,7 +1382,18 @@ exports[`themes lxStudio - theme shape 1`] = `
"--color-yellow-500": "#FFD300",
"--color-yellow-900": "#211B00",
"--elements-headerHeight": "4rem",
- "--elements-headerZ": 15,
+ "--elements-headerZ": 400,
+ "--zIndices-appBar": 400,
+ "--zIndices-base": 0,
+ "--zIndices-flyout": 500,
+ "--zIndices-foreground": 100,
+ "--zIndices-modal": 600,
+ "--zIndices-popover": 700,
+ "--zIndices-portal": 200,
+ "--zIndices-toaster": 800,
+ "--zIndices-tooltip": 900,
+ "--zIndices-underlay": -100,
+ "--zIndices-widget": 300,
"@media only screen and (min-width: 1024px)": {
"--elements-headerHeight": "5rem",
},
@@ -1556,6 +1654,19 @@ monospace",
"8": "0.5rem",
"96": "6rem",
},
+ "zIndices": {
+ "appBar": "var(--zIndices-appBar)",
+ "base": "var(--zIndices-base)",
+ "flyout": "var(--zIndices-flyout)",
+ "foreground": "var(--zIndices-foreground)",
+ "modal": "var(--zIndices-modal)",
+ "popover": "var(--zIndices-popover)",
+ "portal": "var(--zIndices-portal)",
+ "toaster": "var(--zIndices-toaster)",
+ "tooltip": "var(--zIndices-tooltip)",
+ "underlay": "var(--zIndices-underlay)",
+ "widget": "var(--zIndices-widget)",
+ },
}
`;
@@ -1654,7 +1765,7 @@ exports[`themes percipio - theme shape 1`] = `
"base": "4rem",
"md": "5rem",
},
- "headerZ": 15,
+ "headerZ": 400,
},
"modes": {
"dark": {
@@ -1726,6 +1837,19 @@ exports[`themes percipio - theme shape 1`] = `
"text-secondary": "rgba(34, 35, 37, 0.75)",
},
},
+ "zIndices": {
+ "appBar": 400,
+ "base": 0,
+ "flyout": 500,
+ "foreground": 100,
+ "modal": 600,
+ "popover": 700,
+ "portal": 200,
+ "toaster": 800,
+ "tooltip": 900,
+ "underlay": -100,
+ "widget": 300,
+ },
},
"_variables": {
"mode": {
@@ -1848,7 +1972,18 @@ exports[`themes percipio - theme shape 1`] = `
"--color-yellow-500": "#FFD300",
"--color-yellow-900": "#211B00",
"--elements-headerHeight": "4rem",
- "--elements-headerZ": 15,
+ "--elements-headerZ": 400,
+ "--zIndices-appBar": 400,
+ "--zIndices-base": 0,
+ "--zIndices-flyout": 500,
+ "--zIndices-foreground": 100,
+ "--zIndices-modal": 600,
+ "--zIndices-popover": 700,
+ "--zIndices-portal": 200,
+ "--zIndices-toaster": 800,
+ "--zIndices-tooltip": 900,
+ "--zIndices-underlay": -100,
+ "--zIndices-widget": 300,
"@media only screen and (min-width: 1024px)": {
"--elements-headerHeight": "5rem",
},
@@ -2120,6 +2255,19 @@ sans-serif",
"8": "0.5rem",
"96": "6rem",
},
+ "zIndices": {
+ "appBar": "var(--zIndices-appBar)",
+ "base": "var(--zIndices-base)",
+ "flyout": "var(--zIndices-flyout)",
+ "foreground": "var(--zIndices-foreground)",
+ "modal": "var(--zIndices-modal)",
+ "popover": "var(--zIndices-popover)",
+ "portal": "var(--zIndices-portal)",
+ "toaster": "var(--zIndices-toaster)",
+ "tooltip": "var(--zIndices-tooltip)",
+ "underlay": "var(--zIndices-underlay)",
+ "widget": "var(--zIndices-widget)",
+ },
}
`;
@@ -2222,7 +2370,7 @@ exports[`themes platform - theme shape 1`] = `
"base": "4rem",
"md": "5rem",
},
- "headerZ": 15,
+ "headerZ": 400,
},
"modes": {
"dark": {
@@ -2346,6 +2494,19 @@ exports[`themes platform - theme shape 1`] = `
"text-secondary": "rgba(16,22,47,0.75)",
},
},
+ "zIndices": {
+ "appBar": 400,
+ "base": 0,
+ "flyout": 500,
+ "foreground": 100,
+ "modal": 600,
+ "popover": 700,
+ "portal": 200,
+ "toaster": 800,
+ "tooltip": 900,
+ "underlay": -100,
+ "widget": 300,
+ },
},
"_variables": {
"mode": {
@@ -2498,7 +2659,18 @@ exports[`themes platform - theme shape 1`] = `
"--color-yellow-500": "#FFD300",
"--color-yellow-900": "#211B00",
"--elements-headerHeight": "4rem",
- "--elements-headerZ": 15,
+ "--elements-headerZ": 400,
+ "--zIndices-appBar": 400,
+ "--zIndices-base": 0,
+ "--zIndices-flyout": 500,
+ "--zIndices-foreground": 100,
+ "--zIndices-modal": 600,
+ "--zIndices-popover": 700,
+ "--zIndices-portal": 200,
+ "--zIndices-toaster": 800,
+ "--zIndices-tooltip": 900,
+ "--zIndices-underlay": -100,
+ "--zIndices-widget": 300,
"@media only screen and (min-width: 1024px)": {
"--elements-headerHeight": "5rem",
},
@@ -2852,5 +3024,18 @@ monospace",
"8": "0.5rem",
"96": "6rem",
},
+ "zIndices": {
+ "appBar": "var(--zIndices-appBar)",
+ "base": "var(--zIndices-base)",
+ "flyout": "var(--zIndices-flyout)",
+ "foreground": "var(--zIndices-foreground)",
+ "modal": "var(--zIndices-modal)",
+ "popover": "var(--zIndices-popover)",
+ "portal": "var(--zIndices-portal)",
+ "toaster": "var(--zIndices-toaster)",
+ "tooltip": "var(--zIndices-tooltip)",
+ "underlay": "var(--zIndices-underlay)",
+ "widget": "var(--zIndices-widget)",
+ },
}
`;
diff --git a/packages/gamut-styles/src/themes/core.ts b/packages/gamut-styles/src/themes/core.ts
index 3a6da844330..eb4457cdbea 100644
--- a/packages/gamut-styles/src/themes/core.ts
+++ b/packages/gamut-styles/src/themes/core.ts
@@ -11,6 +11,7 @@ import {
lineHeight,
mediaQueries,
spacing,
+ zIndices,
} from '../variables';
/**
@@ -28,6 +29,7 @@ export const coreTheme = createTheme({
fontWeight,
spacing,
elements,
+ zIndices,
})
.addColors(corePalette)
.addColorModes('light', {
@@ -141,6 +143,7 @@ export const coreTheme = createTheme({
2: `2px solid ${colors['border-primary']}`,
}))
.createScaleVariables('elements')
+ .createScaleVariables('zIndices')
.addName('core')
.build();
diff --git a/packages/gamut-styles/src/variables/elements.ts b/packages/gamut-styles/src/variables/elements.ts
index 25bc743bd83..387199e0f28 100644
--- a/packages/gamut-styles/src/variables/elements.ts
+++ b/packages/gamut-styles/src/variables/elements.ts
@@ -1,9 +1,12 @@
+import { zIndices } from './zIndices';
+
export const elements = {
headerHeight: { base: '4rem', md: '5rem' },
/**
- * Semi-arbitrary z-index for global page headers.
- * @remarks PLEASE talk to web platform before adding new z-index constants!
+ * z-index for global page headers. Aliases the `appBar` token from the `zIndices`
+ * scale so consumers still reading `elements.headerZ` stay in sync with the scale.
+ * Prefer `zIndex="appBar"` in new code.
*/
- headerZ: 15,
+ headerZ: zIndices.appBar,
} as const;
diff --git a/packages/gamut-styles/src/variables/index.ts b/packages/gamut-styles/src/variables/index.ts
index d4f074462ef..06ad877133d 100644
--- a/packages/gamut-styles/src/variables/index.ts
+++ b/packages/gamut-styles/src/variables/index.ts
@@ -5,3 +5,4 @@ export * from './responsive';
export * from './spacing';
export * from './timing';
export * from './typography';
+export * from './zIndices';
diff --git a/packages/gamut-styles/src/variables/zIndices.ts b/packages/gamut-styles/src/variables/zIndices.ts
new file mode 100644
index 00000000000..873d947a8ac
--- /dev/null
+++ b/packages/gamut-styles/src/variables/zIndices.ts
@@ -0,0 +1,43 @@
+/**
+ * Semantic z-index scale. A single scale covers both in-flow layers (low values) and
+ * portal layers (≥100). Every z-index in Gamut should reference a token here rather than a
+ * magic number.
+ *
+ * The `zIndex` prop is intentionally left numeric/unscaled, and this object is numeric, so
+ * tokens are used as `zIndex={zIndices.modal}`. That preserves the escape hatch (a raw
+ * in-between number, e.g. `zIndex={605}`) and arithmetic on tokens (e.g. `zIndices.foreground - 2`).
+ *
+ * Portal-band values are spaced by 100 so in-between escape-hatch numbers are available.
+ * `portal` (200) is the floor of the portal band and the default for `BodyPortal`.
+ *
+ * @remarks PLEASE talk to web platform before adding new z-index tokens.
+ */
+export const zIndices = {
+ /** Decorative layer behind content (underlines, backdrops, shadows). */
+ underlay: -100,
+ /** Ground layer — establishes a local stacking context without lifting above siblings. */
+ base: 0,
+ /**
+ * The raised in-flow layer: an element in front of what sits/scrolls behind it, but below
+ * all portal overlays. Covers content lifted above an `underlay` (e.g. text over its
+ * underline) and sticky content headers (e.g. a sticky table `thead`). Set to 100 so it
+ * clears common ad-hoc low z-index values.
+ */
+ foreground: 100,
+ /** Portal floor / `BodyPortal` default — un-tokenized portal content lands here. */
+ portal: 200,
+ /** Persistent floating page furniture at rest (e.g. an AI chat launcher, help bubble). */
+ widget: 300,
+ /** Global app header / nav bar. Aliased by the legacy `elements.headerZ` constant. */
+ appBar: 400,
+ /** Portaled side panel (the `Flyout` component = `Drawer` inside `Overlay`). */
+ flyout: 500,
+ /** `Overlay`, `Modal`, and `Dialog` (they share one portal primitive). */
+ modal: 600,
+ /** Portal-mode `Popover` and the portaled `SelectDropdown` menu — above modal. */
+ popover: 700,
+ /** Toasts / notifications — visible above modals. */
+ toaster: 800,
+ /** Floating tooltips (`FloatingTip`) — highest, so they are never clipped. */
+ tooltip: 900,
+} as const;
diff --git a/packages/gamut-styles/src/variance/config.ts b/packages/gamut-styles/src/variance/config.ts
index ad2b6e3fc23..1cacdaef3f1 100644
--- a/packages/gamut-styles/src/variance/config.ts
+++ b/packages/gamut-styles/src/variance/config.ts
@@ -328,6 +328,9 @@ export const positioning = {
resolveProperty: getPropertyMode,
transform: transformSize,
},
+ // Intentionally unscaled: scaled variance props are token-only and reject raw numbers,
+ // which would break the numeric escape hatch and Tip's z-index arithmetic. Use the numeric
+ // `zIndices` token object instead (e.g. zIndex={zIndices.modal}).
zIndex: { property: 'zIndex' },
opacity: { property: 'opacity' },
} as const;
diff --git a/packages/gamut/src/Anchor/index.tsx b/packages/gamut/src/Anchor/index.tsx
index 287ff0e6d37..b65906656eb 100644
--- a/packages/gamut/src/Anchor/index.tsx
+++ b/packages/gamut/src/Anchor/index.tsx
@@ -1,4 +1,9 @@
-import { styledOptions, system, variant } from '@codecademy/gamut-styles';
+import {
+ styledOptions,
+ system,
+ variant,
+ zIndices,
+} from '@codecademy/gamut-styles';
import { StyleProps, variance } from '@codecademy/variance';
import styled from '@emotion/styled';
import { ComponentProps, forwardRef, HTMLProps, RefObject } from 'react';
@@ -21,7 +26,7 @@ const outlineFocusVisible = {
border: 2,
borderColor: 'primary',
opacity: 0,
- zIndex: 0,
+ zIndex: zIndices.base,
},
[ButtonSelectors.OUTLINE_FOCUS_VISIBLE]: {
diff --git a/packages/gamut/src/BarChart/layout/GridLines.tsx b/packages/gamut/src/BarChart/layout/GridLines.tsx
index 14db738f132..bf1c9718c98 100644
--- a/packages/gamut/src/BarChart/layout/GridLines.tsx
+++ b/packages/gamut/src/BarChart/layout/GridLines.tsx
@@ -1,4 +1,4 @@
-import { css } from '@codecademy/gamut-styles';
+import { css, zIndices } from '@codecademy/gamut-styles';
import styled from '@emotion/styled';
import React, { useMemo } from 'react';
@@ -12,7 +12,7 @@ const GridLineWrapper = styled(Box)(
inset: 0,
pointerEvents: 'none',
position: 'absolute',
- zIndex: 0,
+ zIndex: zIndices.base,
})
);
diff --git a/packages/gamut/src/BodyPortal/index.tsx b/packages/gamut/src/BodyPortal/index.tsx
index 5da951b486b..34309f27739 100644
--- a/packages/gamut/src/BodyPortal/index.tsx
+++ b/packages/gamut/src/BodyPortal/index.tsx
@@ -1,4 +1,9 @@
-import { ColorMode, system, useCurrentMode } from '@codecademy/gamut-styles';
+import {
+ ColorMode,
+ system,
+ useCurrentMode,
+ zIndices,
+} from '@codecademy/gamut-styles';
import styled from '@emotion/styled';
import { useState } from 'react';
import * as React from 'react';
@@ -19,16 +24,17 @@ const PortalWrapper = styled
interface BodyPortalProps {
/**
- * TEMPORARY: a stopgap solution to avoid zIndex conflicts -
- * will be reworked with: GM-624
- * previously, zIndex was set to 1 in the CSS function
+ * Stacking layer for the portaled content. Pass a `zIndices` token
+ * (e.g. `zIndices.modal`) or a raw number as an escape hatch. Defaults to
+ * `zIndices.portal` — the floor of the portal band — so un-tokenized portal content
+ * stays above local page content instead of silently landing at a low value.
*/
zIndex?: number;
}
export const BodyPortal: React.FC> = ({
children,
- zIndex = 1,
+ zIndex = zIndices.portal,
}) => {
const [ready, setReady] = useState(false);
const mode = useCurrentMode();
diff --git a/packages/gamut/src/Box/props.ts b/packages/gamut/src/Box/props.ts
index 536ff575392..0d53c0a39a9 100644
--- a/packages/gamut/src/Box/props.ts
+++ b/packages/gamut/src/Box/props.ts
@@ -1,4 +1,4 @@
-import { system } from '@codecademy/gamut-styles';
+import { system, zIndices } from '@codecademy/gamut-styles';
import { StyleProps, variance } from '@codecademy/variance';
import { WithChildrenProp } from '../utils';
@@ -24,7 +24,7 @@ export const sharedStates = system.states({
},
context: {
position: 'relative',
- zIndex: 1,
+ zIndex: zIndices.foreground,
},
'no-select': {
WebkitTouchCallout: 'none',
diff --git a/packages/gamut/src/Button/shared/styles.ts b/packages/gamut/src/Button/shared/styles.ts
index 9bcf0cbed8f..534aa43b7de 100644
--- a/packages/gamut/src/Button/shared/styles.ts
+++ b/packages/gamut/src/Button/shared/styles.ts
@@ -4,6 +4,7 @@ import {
styledOptions,
system,
transitionConcat,
+ zIndices,
} from '@codecademy/gamut-styles';
import { CSSObject, ThemeProps, variance } from '@codecademy/variance';
import styled from '@emotion/styled';
@@ -67,7 +68,7 @@ export const buttonStyles = system.css({
border: 2,
inset: -5,
opacity: 0,
- zIndex: 0,
+ zIndex: zIndices.base,
},
[ButtonSelectors.OUTLINE_FOCUS_VISIBLE]: {
opacity: 1,
diff --git a/packages/gamut/src/DataList/EmptyRows.tsx b/packages/gamut/src/DataList/EmptyRows.tsx
index 3684664b57e..811fb5fb99a 100644
--- a/packages/gamut/src/DataList/EmptyRows.tsx
+++ b/packages/gamut/src/DataList/EmptyRows.tsx
@@ -1,3 +1,5 @@
+import { zIndices } from '@codecademy/gamut-styles';
+
import { FlexBox } from '../Box';
import { FillButton } from '../Button';
import { Text } from '../Typography';
@@ -15,7 +17,7 @@ export const EmptyRows = () => {
position="sticky"
top="calc(50% - 66px)"
width="320px"
- zIndex={1}
+ zIndex={zIndices.foreground}
>
diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarWrapper.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarWrapper.tsx
index 425348fa6dd..29e2b477461 100644
--- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarWrapper.tsx
+++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarWrapper.tsx
@@ -1,4 +1,5 @@
import { CheckerDense } from '@codecademy/gamut-patterns';
+import { zIndices } from '@codecademy/gamut-styles';
import * as React from 'react';
import { Box } from '../../../Box';
@@ -12,7 +13,7 @@ export const CalendarWrapper: React.FC = ({ children }) => (
border={1}
borderRadius="sm"
position="relative"
- zIndex={1}
+ zIndex={zIndices.foreground}
>
{children}
diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/elements.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/elements.tsx
index b5a7354e8d7..6a992bb7969 100644
--- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/elements.tsx
+++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/elements.tsx
@@ -1,4 +1,9 @@
-import { css, states, transitionConcat } from '@codecademy/gamut-styles';
+import {
+ css,
+ states,
+ transitionConcat,
+ zIndices,
+} from '@codecademy/gamut-styles';
import { StyleProps } from '@codecademy/variance';
import styled from '@emotion/styled';
@@ -119,7 +124,7 @@ export const DateCell = styled.td(
borderRadius: 'lg',
border: 2,
opacity: 0,
- zIndex: 0,
+ zIndex: zIndices.base,
},
'&:focus-visible::before': {
opacity: 1,
diff --git a/packages/gamut/src/Flyout/index.tsx b/packages/gamut/src/Flyout/index.tsx
index 8bd38fc0ccf..adfee0e0c4c 100644
--- a/packages/gamut/src/Flyout/index.tsx
+++ b/packages/gamut/src/Flyout/index.tsx
@@ -1,5 +1,5 @@
import { MiniDeleteIcon } from '@codecademy/gamut-icons';
-import { Background, Colors } from '@codecademy/gamut-styles';
+import { Background, Colors, zIndices } from '@codecademy/gamut-styles';
import * as React from 'react';
import { FlexBox } from '../Box';
@@ -52,6 +52,7 @@ export const Flyout: React.FC = ({
escapeCloses
isOpen={expanded}
shroud
+ zIndex={zIndices.flyout}
onRequestClose={onClose}
>
diff --git a/packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx b/packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx
index f31839eaa46..603b626cbfe 100644
--- a/packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx
+++ b/packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx
@@ -276,6 +276,9 @@ export const SelectDropdown: React.FC = ({
isOptionDisabled={(option) => option.disabled}
isSearchable={isSearchable}
menuAlignment={menuAlignment}
+ menuPortalTarget={
+ typeof document !== 'undefined' ? document.body : undefined
+ }
name={name}
options={selectOptions}
placeholder={placeholder}
diff --git a/packages/gamut/src/Form/SelectDropdown/styles.ts b/packages/gamut/src/Form/SelectDropdown/styles.ts
index ba9178c113c..281e1e87f8c 100644
--- a/packages/gamut/src/Form/SelectDropdown/styles.ts
+++ b/packages/gamut/src/Form/SelectDropdown/styles.ts
@@ -3,6 +3,7 @@ import {
states,
theme as GamutTheme,
variant,
+ zIndices,
} from '@codecademy/gamut-styles';
import { StylesConfig } from 'react-select';
@@ -25,7 +26,6 @@ import { BaseSelectComponentProps } from './types/styles';
const selectDropdownStyles = css({
...formBaseFieldStylesObject,
display: 'flex',
- zIndex: 3,
});
const selectFocusStyles = {
@@ -164,6 +164,12 @@ export const getMemoizedStyles = (
: {}),
};
},
+ menuPortal: (provided) => ({
+ ...provided,
+ // The menu is portaled to the body, so it stacks at the page root as a popover —
+ // above sticky headers and modal content. A raw `zIndex` prop overrides as an escape hatch.
+ zIndex: zIndex ?? zIndices.popover,
+ }),
menuList: (provided, state: BaseSelectComponentProps) => {
const sizeInteger = state.selectProps.size === 'small' ? 2 : 3;
const maxHeight = `${
diff --git a/packages/gamut/src/List/TableHeader.tsx b/packages/gamut/src/List/TableHeader.tsx
index 60810da9c91..63add861255 100644
--- a/packages/gamut/src/List/TableHeader.tsx
+++ b/packages/gamut/src/List/TableHeader.tsx
@@ -1,3 +1,4 @@
+import { zIndices } from '@codecademy/gamut-styles';
import { ComponentProps, forwardRef } from 'react';
import { Box } from '../Box';
@@ -12,7 +13,13 @@ export const TableHeader = forwardRef(
({ children, ...rest }, ref) => {
const { spacing, scrollable, variant } = useListContext();
return (
-
+
(
flexDirection: { _: 'row', c_base: 'column', c_sm: 'row' },
top: 0,
bg: 'background-current',
- zIndex: 2,
+ zIndex: zIndices.foreground,
fontFamily: 'accent',
pb: { _: 0, c_base: 8, c_sm: 0 },
}),
@@ -468,7 +469,7 @@ export const StickyHeaderColWrapper = styled.th(
height: '100%',
top: 0,
left: 0,
- zIndex: -1,
+ zIndex: zIndices.underlay,
},
'&:after': {
content: '""',
@@ -482,7 +483,7 @@ export const StickyHeaderColWrapper = styled.th(
height: '100%',
top: 0,
left: 0,
- zIndex: -1,
+ zIndex: zIndices.underlay,
},
// p: 0 removes the browser's default padding of 1px
p: 0,
@@ -490,7 +491,7 @@ export const StickyHeaderColWrapper = styled.th(
flexShrink: 0,
position: 'sticky',
left: 0,
- zIndex: 1,
+ zIndex: zIndices.foreground,
bg: { _: 'inherit', c_base: 'transparent', c_sm: 'inherit' },
'&:not(:first-of-type)': {
left: { _: 16, c_base: 0, c_sm: 16 },
diff --git a/packages/gamut/src/Menu/elements.tsx b/packages/gamut/src/Menu/elements.tsx
index f8eea4af20d..0a36aed2f91 100644
--- a/packages/gamut/src/Menu/elements.tsx
+++ b/packages/gamut/src/Menu/elements.tsx
@@ -2,6 +2,7 @@ import {
styledOptions,
system,
transitionConcat,
+ zIndices,
} from '@codecademy/gamut-styles';
import { StyleProps, variance } from '@codecademy/variance';
import styled from '@emotion/styled';
@@ -88,7 +89,7 @@ const interactiveVariants = system.variant({
alignItems: 'center',
cursor: 'pointer',
width: 1,
- zIndex: 1,
+ zIndex: zIndices.foreground,
px: 24,
py: 12,
position: 'relative',
@@ -108,7 +109,7 @@ const interactiveVariants = system.variant({
border: 2,
borderColor: 'primary',
opacity: 0,
- zIndex: -1,
+ zIndex: zIndices.underlay,
},
[MenuItemSelectors.OUTLINE_FOCUS_VISIBLE]: {
opacity: 1,
@@ -224,7 +225,7 @@ const StyledListLink = styled('a', styledOptions<'a'>())(
export const ListLink = forwardRef<
HTMLAnchorElement,
ComponentProps
->(({ zIndex = 1, ...rest }, ref) => (
+>(({ zIndex = zIndices.foreground, ...rest }, ref) => (
));
diff --git a/packages/gamut/src/Overlay/index.tsx b/packages/gamut/src/Overlay/index.tsx
index 9ddda4854d0..e4512961341 100644
--- a/packages/gamut/src/Overlay/index.tsx
+++ b/packages/gamut/src/Overlay/index.tsx
@@ -1,4 +1,4 @@
-import { states } from '@codecademy/gamut-styles';
+import { states, zIndices } from '@codecademy/gamut-styles';
import styled from '@emotion/styled';
import { useCallback } from 'react';
import * as React from 'react';
@@ -34,8 +34,9 @@ export type OverlayProps = {
/** Whether the overlay allows scroll */
allowScroll?: boolean;
/**
- * z-index for the Overlay. Defaults to 3 to appear above common UI elements
- * like headers . Can be overridden when needed for custom stacking orders.
+ * Stacking layer for the Overlay. Pass a `zIndices` token or a raw number (escape
+ * hatch). Defaults to `zIndices.modal`; a portaled side panel should pass
+ * `zIndices.flyout` to sit below modals.
*/
zIndex?: number;
};
@@ -61,7 +62,7 @@ export const Overlay: React.FC = ({
onRequestClose,
isOpen,
allowScroll = false,
- zIndex = 3,
+ zIndex = zIndices.modal,
}) => {
const handleOutsideClick = useCallback(() => {
if (clickOutsideCloses) {
diff --git a/packages/gamut/src/PatternBackdrop/PatternBackdrop.tsx b/packages/gamut/src/PatternBackdrop/PatternBackdrop.tsx
index c3d6bcd8b15..3bf50b9e0be 100644
--- a/packages/gamut/src/PatternBackdrop/PatternBackdrop.tsx
+++ b/packages/gamut/src/PatternBackdrop/PatternBackdrop.tsx
@@ -1,5 +1,5 @@
import { CheckerDense } from '@codecademy/gamut-patterns';
-import { styledOptions, system } from '@codecademy/gamut-styles';
+import { styledOptions, system, zIndices } from '@codecademy/gamut-styles';
import { StyleProps, variance } from '@codecademy/variance';
import styled from '@emotion/styled';
import { ComponentProps, forwardRef } from 'react';
@@ -13,7 +13,7 @@ const PatternBackdropBody = styled('div', styledOptions)<
>(
system.css({
position: 'relative',
- zIndex: 1,
+ zIndex: zIndices.foreground,
bg: 'background',
border: 1,
maxWidth: 1,
@@ -29,7 +29,12 @@ type PatternBackdropProps = ComponentProps;
*/
export const PatternBackdrop = forwardRef(
({ children, ...rest }, ref) => (
-
+
{isOpen && (
-
+
) : (
-
+
);
export type PopoverContainerProps = Pick;
diff --git a/packages/gamut/src/Popover/styles/base.ts b/packages/gamut/src/Popover/styles/base.ts
index d3aae9f9c2c..59fcf3c5545 100644
--- a/packages/gamut/src/Popover/styles/base.ts
+++ b/packages/gamut/src/Popover/styles/base.ts
@@ -1,4 +1,4 @@
-import { states, variant } from '@codecademy/gamut-styles';
+import { states, variant, zIndices } from '@codecademy/gamut-styles';
import { toolTipBodyCss } from '../../Tip/shared/styles/styles';
@@ -31,7 +31,7 @@ export const popoverStates = states({
export const raisedDivVariants = variant({
base: {
- zIndex: 1,
+ zIndex: zIndices.foreground,
},
defaultVariant: 'primary',
variants: {
diff --git a/packages/gamut/src/Popover/styles/variants.ts b/packages/gamut/src/Popover/styles/variants.ts
index 1ebf78d59e7..ba22712decf 100644
--- a/packages/gamut/src/Popover/styles/variants.ts
+++ b/packages/gamut/src/Popover/styles/variants.ts
@@ -1,4 +1,4 @@
-import { states, variant } from '@codecademy/gamut-styles';
+import { states, variant, zIndices } from '@codecademy/gamut-styles';
import { createVariantsFromAlignments } from '../../Tip/shared/styles/createVariantsUtils';
import { tooltipArrowHeight } from '../../Tip/shared/styles/styles';
@@ -47,7 +47,7 @@ const beakVariantStyles = createVariantsFromAlignments(
export const beakVariants = variant({
base: {
background: 'transparent',
- zIndex: 1,
+ zIndex: zIndices.foreground,
position: 'fixed',
},
prop: 'beak',
diff --git a/packages/gamut/src/PopoverContainer/PopoverContainer.tsx b/packages/gamut/src/PopoverContainer/PopoverContainer.tsx
index c93f1965bc8..0259a1e9272 100644
--- a/packages/gamut/src/PopoverContainer/PopoverContainer.tsx
+++ b/packages/gamut/src/PopoverContainer/PopoverContainer.tsx
@@ -1,4 +1,9 @@
-import { elementDir, system, useElementDir } from '@codecademy/gamut-styles';
+import {
+ elementDir,
+ system,
+ useElementDir,
+ zIndices,
+} from '@codecademy/gamut-styles';
import { variance } from '@codecademy/variance';
import styled from '@emotion/styled';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -282,5 +287,5 @@ export const PopoverContainer: React.FC = ({
if (inline) return content;
- return {content};
+ return {content};
};
diff --git a/packages/gamut/src/Tabs/TabButton.tsx b/packages/gamut/src/Tabs/TabButton.tsx
index 0cbaf3f2582..fc150c6560a 100644
--- a/packages/gamut/src/Tabs/TabButton.tsx
+++ b/packages/gamut/src/Tabs/TabButton.tsx
@@ -1,4 +1,4 @@
-import { states, variant } from '@codecademy/gamut-styles';
+import { states, variant, zIndices } from '@codecademy/gamut-styles';
import { StyleProps } from '@codecademy/variance';
import styled from '@emotion/styled';
@@ -29,7 +29,7 @@ const focusVisibleStyles = {
borderColor: 'primary',
position: 'absolute',
inset: 0,
- zIndex: 0,
+ zIndex: zIndices.base,
borderRadiusTop: 'md',
borderRadiusBottom: 'none',
},
@@ -48,7 +48,7 @@ export const tabVariants = variant({
color: 'text',
font: 'inherit',
cursor: 'pointer',
- zIndex: 1,
+ zIndex: zIndices.foreground,
[TabSelectors.DISABLED]: {
opacity: 0.25,
cursor: 'not-allowed',
diff --git a/packages/gamut/src/Tabs/Tabs.tsx b/packages/gamut/src/Tabs/Tabs.tsx
index 3194df06756..13332160986 100644
--- a/packages/gamut/src/Tabs/Tabs.tsx
+++ b/packages/gamut/src/Tabs/Tabs.tsx
@@ -1,4 +1,4 @@
-import { Background } from '@codecademy/gamut-styles';
+import { Background, zIndices } from '@codecademy/gamut-styles';
import { StyleProps } from '@codecademy/variance';
import styled from '@emotion/styled';
import * as React from 'react';
@@ -25,10 +25,10 @@ export const Tabs: React.FC = (props) => {
{/* currently only supporting dark mode for the LE variant. */}
{props.variant === 'block' ? (
-
+
) : (
-
+
)}
);
diff --git a/packages/gamut/src/Tabs/styles.tsx b/packages/gamut/src/Tabs/styles.tsx
index 68d2579f9fb..e445e6c8686 100644
--- a/packages/gamut/src/Tabs/styles.tsx
+++ b/packages/gamut/src/Tabs/styles.tsx
@@ -1,4 +1,4 @@
-import { system, variant } from '@codecademy/gamut-styles';
+import { system, variant, zIndices } from '@codecademy/gamut-styles';
export const tabContainerVariants = variant({
base: {
@@ -15,7 +15,7 @@ export const tabContainerVariants = variant({
bg: 'text',
position: 'absolute',
bottom: 0,
- zIndex: 0,
+ zIndex: zIndices.base,
width: '100%',
},
},
diff --git a/packages/gamut/src/Tip/PreviewTip/elements.tsx b/packages/gamut/src/Tip/PreviewTip/elements.tsx
index 6f42308c3c4..21cde41ef91 100644
--- a/packages/gamut/src/Tip/PreviewTip/elements.tsx
+++ b/packages/gamut/src/Tip/PreviewTip/elements.tsx
@@ -1,5 +1,5 @@
import { CheckerDense } from '@codecademy/gamut-patterns';
-import { css, variant } from '@codecademy/gamut-styles';
+import { css, variant, zIndices } from '@codecademy/gamut-styles';
import styled from '@emotion/styled';
import { useMemo } from 'react';
@@ -147,7 +147,7 @@ export const PreviewTipShadow: React.FC = ({
return (
diff --git a/packages/gamut/src/Tip/shared/InlineTip.tsx b/packages/gamut/src/Tip/shared/InlineTip.tsx
index 871cfaac1ac..b59608e1459 100644
--- a/packages/gamut/src/Tip/shared/InlineTip.tsx
+++ b/packages/gamut/src/Tip/shared/InlineTip.tsx
@@ -1,3 +1,5 @@
+import { zIndices } from '@codecademy/gamut-styles';
+
import { InfoTipContainer } from '../InfoTip/styles';
import { PreviewTipContents, PreviewTipShadow } from '../PreviewTip/elements';
import { ToolTipContainer } from '../ToolTip/elements';
@@ -55,7 +57,7 @@ export const InlineTip: React.FC = ({
const tipBody = (
= ({
colorMode = 'light',
}) => {
return (
-
+
diff --git a/packages/gamut/src/Typography/Text.tsx b/packages/gamut/src/Typography/Text.tsx
index 173661b8632..a05bf9d53d0 100644
--- a/packages/gamut/src/Typography/Text.tsx
+++ b/packages/gamut/src/Typography/Text.tsx
@@ -3,6 +3,7 @@ import {
styledOptions,
system,
variant,
+ zIndices,
} from '@codecademy/gamut-styles';
import { StyleProps, variance } from '@codecademy/variance';
import styled from '@emotion/styled';
@@ -76,7 +77,7 @@ const textStates = states({
fontWeight: 'bold',
minWidth: '0.4rem',
position: 'relative',
- zIndex: 1,
+ zIndex: zIndices.foreground,
// the text is more legible against the background color with text smoothing
MozOsxFontSmoothing: 'grayscale',
WebkitFontSmoothing: 'antialiased',
@@ -90,7 +91,7 @@ const textStates = states({
position: 'absolute',
top: '50%',
width: 'calc(100% + 0.4rem)',
- zIndex: -1,
+ zIndex: zIndices.underlay,
},
},
screenreader: {
diff --git a/packages/styleguide/src/lib/Foundations/ZIndex.mdx b/packages/styleguide/src/lib/Foundations/ZIndex.mdx
new file mode 100644
index 00000000000..d597f7f639f
--- /dev/null
+++ b/packages/styleguide/src/lib/Foundations/ZIndex.mdx
@@ -0,0 +1,75 @@
+import { Meta } from '@storybook/addon-docs/blocks';
+
+import { ComponentHeader, LinkTo, TokenTable } from '~styleguide/blocks';
+
+import * as TABLES from './shared/elements';
+
+export const parameters = {
+ title: 'Z-Index',
+ subtitle:
+ 'A single semantic scale for coordinating stacking order across Gamut.',
+ status: 'static',
+};
+
+
+
+
+
+Gamut exposes one semantic z-index scale, `zIndices`, so stacking order is coordinated by name
+instead of by scattered magic numbers. Import it from `@codecademy/gamut-styles` and pass a token to
+the `zIndex` prop:
+
+```tsx
+import { zIndices } from '@codecademy/gamut-styles';
+
+…;
+```
+
+## The scale
+
+Values run in two bands. **In-flow** tokens (below `0`–`100`) order elements relative to their
+siblings inside a normal stacking context. **Portal** tokens (`200`+) order elements that render
+into a portal at `document.body` and compete at the page root. Portal values are spaced by 100.
+
+
+
+- **`underlay` → `foreground`** are in-flow: a decorative layer behind content, a neutral ground
+ that establishes a stacking context, and content raised in front (including sticky headers).
+- **`portal` → `tooltip`** are the portal layers, ordered so the things that must never be clipped
+ (tooltips, toasts, popovers) sit above dialogs, which sit above side panels and the app bar.
+- `portal` is the floor of the portal band and the default for `BodyPortal`, so portaled content
+ that omits a specific token still lands above page content.
+
+## Why it's a _soft_ scale
+
+Unlike hard token scales such as borderRadii or spacing —
+where the prop is bound to the scale and **only** accepts token values — the `zIndex` prop is left
+**numeric**. `zIndices` is just a numeric object you reference by name; the prop still accepts a raw
+number. This is deliberate:
+
+- **Escape hatch.** Stacking occasionally needs a one-off value between two layers (e.g. a confirm
+ dialog nested inside a modal). You can pass a raw in-between number without leaving the system:
+
+ ```tsx
+ …; // just above modal
+ …; // deliberate one-off
+ ```
+
+- **Arithmetic.** Some components derive a relative value from a base (e.g. a tooltip's shadow sits
+ a step behind its body: `zIndices.foreground - 2`). A token-only string prop can't express that.
+
+Binding `zIndex` to the scale as a hard token would break both of the above (variance scaled props
+reject raw numbers), so it stays soft. To keep it from sliding back into magic numbers, the
+`gamut/no-raw-z-index` lint rule flags bare numeric z-index values and steers you to a token; a
+genuine escape hatch uses an inline `eslint-disable` with a short justification.
+
+## Guidance
+
+- **Reach for a token first.** `zIndices.modal`, `zIndices.foreground`, etc. — the name documents
+ intent far better than a number.
+- **In-flow vs. portal.** Use the in-flow tokens for stacking within a component; only portaled
+ content belongs on the `portal`+ band.
+- **Escape hatch sparingly.** A raw number is fine for a genuine one-off, but prefer
+ `token ± n` so the relationship to the scale stays visible, and leave a comment.
+- **Third-party widgets** (e.g. injected marketing/chat scripts) set their own z-index and are out
+ of Gamut's control; `tooltip` is the highest Gamut layer.
diff --git a/packages/styleguide/src/lib/Foundations/shared/elements.tsx b/packages/styleguide/src/lib/Foundations/shared/elements.tsx
index 1ec160156af..a74f1e51a82 100644
--- a/packages/styleguide/src/lib/Foundations/shared/elements.tsx
+++ b/packages/styleguide/src/lib/Foundations/shared/elements.tsx
@@ -6,6 +6,7 @@ import {
lxStudioColors,
theme,
trueColors,
+ zIndices as zIndicesTokens,
} from '@codecademy/gamut-styles';
// eslint-disable-next-line gamut/import-paths
import * as ALL_PROPS from '@codecademy/gamut-styles/src/variance/config';
@@ -438,6 +439,91 @@ export const borderRadii = {
],
};
+// Representative components that use each token, linked to their Storybook stories.
+// Story ids are the folder path under `src/lib` (Storybook auto-title).
+const zIndexExamples: Record = {
+ underlay: [
+ { label: 'Text', id: 'Typography/Text' },
+ { label: 'Menu', id: 'Molecules/Menu' },
+ ],
+ base: [
+ { label: 'Tabs', id: 'Molecules/Tabs' },
+ { label: 'Anchor', id: 'Typography/Anchor' },
+ ],
+ foreground: [
+ { label: 'Text', id: 'Typography/Text' },
+ { label: 'Tabs', id: 'Molecules/Tabs' },
+ { label: 'DataList', id: 'Organisms/Lists & Tables/DataList' },
+ ],
+ flyout: [{ label: 'Flyout', id: 'Molecules/Flyout' }],
+ modal: [
+ { label: 'Modal', id: 'Molecules/Modals/Modal' },
+ { label: 'Dialog', id: 'Molecules/Modals/Dialog' },
+ ],
+ popover: [
+ { label: 'Popover', id: 'Molecules/Popover' },
+ { label: 'SelectDropdown', id: 'Atoms/FormInputs/SelectDropdown' },
+ ],
+ toaster: [{ label: 'Toaster', id: 'Molecules/Toasts/Toaster' }],
+ tooltip: [{ label: 'ToolTip', id: 'Molecules/Tips/ToolTip' }],
+};
+
+// Tokens with no single component example get a short note instead of links.
+const zIndexNotes: Record = {
+ portal: 'BodyPortal default',
+ widget: 'Floating launchers (none in Gamut yet)',
+ appBar: 'App header / nav (app-owned)',
+};
+
+export const zIndices = {
+ // Object insertion order runs low → high (underlay … tooltip).
+ rows: Object.entries(zIndicesTokens).map(([id, value]) => ({
+ id,
+ value,
+ })),
+ columns: [
+ { ...PROP_COLUMN, name: 'Token' },
+ {
+ ...PATH_COLUMN,
+ render: ({ id }: any) => zIndices.{id},
+ },
+ VALUE_COLUMN,
+ {
+ key: 'band',
+ name: 'Band',
+ size: 'lg',
+ render: ({ value }: any) => (
+ {value >= zIndicesTokens.portal ? 'portal' : 'in-flow'}
+ ),
+ },
+ {
+ key: 'usedBy',
+ name: 'Used by',
+ size: 'lg',
+ render: ({ id }: any) => {
+ const examples = zIndexExamples[id];
+ if (!examples?.length) {
+ return {zIndexNotes[id] ?? '—'};
+ }
+ return (
+ <>
+ {examples.flatMap((example, i) => {
+ const link = (
+
+ {example.label}
+
+ );
+ return i === 0
+ ? [link]
+ : [, , link];
+ })}
+ >
+ );
+ },
+ },
+ ],
+};
+
export const LightModeTable = () => (
|