From 414d854079a205cf52ad238dfecc0a0902a8848b Mon Sep 17 00:00:00 2001
From: Clint Andrew Hall
Date: Thu, 9 Jul 2026 17:04:48 -0400
Subject: [PATCH 1/3] [EuiIllustration] Add adaptive light-dark illustration
variant
Generate a single `adaptive` SVG per asset by merging the `light`/`dark` pair, rewriting differing colors into CSS `light-dark()` so a single string recolors via the ancestor `color-scheme` with no runtime SVG swap. Assets whose `light`/`dark` markup is not structurally identical keep `light`/`dark` only. The adaptive SVG is also emitted as `@elastic/eui-illustrations/svgs/.adaptive.svg` for ``/CSS consumers.
`EuiIllustration` now renders the adaptive SVG when present, setting `color-scheme` from the active EUI theme, and falls back to the discrete `light`/`dark` markup otherwise.
---
.../upcoming/adaptive_illustrations.md | 1 +
.../illustration/illustration.stories.tsx | 167 +++++++++++++++-
.../illustration/illustration.test.tsx | 45 +++++
.../components/illustration/illustration.tsx | 29 ++-
packages/illustrations/README.md | 36 +++-
.../upcoming/adaptive_illustrations.md | 1 +
packages/illustrations/package.json | 6 +-
packages/illustrations/scripts/generate.js | 187 +++++++++++++++++-
packages/illustrations/src/types.ts | 7 +
.../display/illustrations/index.mdx | 42 +++-
10 files changed, 503 insertions(+), 18 deletions(-)
create mode 100644 packages/eui/changelogs/upcoming/adaptive_illustrations.md
create mode 100644 packages/illustrations/changelogs/upcoming/adaptive_illustrations.md
diff --git a/packages/eui/changelogs/upcoming/adaptive_illustrations.md b/packages/eui/changelogs/upcoming/adaptive_illustrations.md
new file mode 100644
index 00000000000..1613f093b1a
--- /dev/null
+++ b/packages/eui/changelogs/upcoming/adaptive_illustrations.md
@@ -0,0 +1 @@
+- Updated `EuiIllustration` to render the color-mode-adaptive SVG when an asset provides one, setting `color-scheme` from the active theme so its colors resolve via CSS `light-dark()`. It falls back to the discrete `light`/`dark` markup otherwise.
diff --git a/packages/eui/src/components/illustration/illustration.stories.tsx b/packages/eui/src/components/illustration/illustration.stories.tsx
index 9095a22f457..d2199149413 100644
--- a/packages/eui/src/components/illustration/illustration.stories.tsx
+++ b/packages/eui/src/components/illustration/illustration.stories.tsx
@@ -16,6 +16,10 @@ import { hideAllStorybookControls } from '../../../.storybook/utils';
import { useEuiTheme } from '../../services';
import { EuiButton } from '../button';
import { EuiEmptyPrompt } from '../empty_prompt';
+import { EuiFlexGroup, EuiFlexItem } from '../flex';
+import { EuiPanel } from '../panel';
+import { EuiSpacer } from '../spacer';
+import { EuiText } from '../text';
import {
EuiIllustration,
EuiIllustrationProps,
@@ -59,7 +63,7 @@ export const Playground: Story = {
if (fullWidth) props.push('fullWidth');
return `import { ${type} } from '@elastic/eui-illustrations';
-
+
`;
},
},
@@ -120,6 +124,52 @@ export const EmptyPrompt: Story = {
),
};
+const ADAPTIVE_SNIPPET = `import { useEuiTheme } from '@elastic/eui';
+import { shoppingCart } from '@elastic/eui-illustrations';
+
+// One string. The ancestor \`color-scheme\` picks which \`light-dark()\` value
+// applies. Pin it (\`light\`/\`dark\`), follow the OS \`prefers-color-scheme\`
+// (\`light dark\`), or mirror the EUI theme (\`EuiProvider\`).
+const PROVIDER_SCHEME = 'EuiProvider';
+const SYSTEM_SCHEME = 'system';
+const schemes = ['light', 'dark', PROVIDER_SCHEME, SYSTEM_SCHEME] as const;
+
+const AdaptiveIllustrations = () => {
+ const { colorMode } = useEuiTheme();
+ const providerScheme = colorMode === 'DARK' ? 'dark' : 'light';
+
+ const resolveScheme = (scheme) => {
+ if (scheme === PROVIDER_SCHEME) return providerScheme;
+ if (scheme === SYSTEM_SCHEME) return 'light dark';
+ return scheme;
+ };
+
+ return schemes.map((scheme) => (
+
+ ));
+};`;
+
+/**
+ * Most assets ship a single \`adaptive\` SVG whose colors resolve via CSS
+ * \`light-dark()\`. \`EuiIllustration\` sets \`color-scheme\` from the EUI theme;
+ * this story sets it manually so the same string renders pinned \`light\`,
+ * pinned \`dark\`, following \`EuiProvider\`, and following the OS
+ * (\`light dark\`, via \`prefers-color-scheme\`) at once.
+ * \`aerospace\` has no \`adaptive\` variant and cannot adapt.
+ */
+export const Adaptive: Story = {
+ parameters: {
+ vrt: { skip: true },
+ codeSnippet: { snippet: ADAPTIVE_SNIPPET },
+ ...hideAllStorybookControls,
+ },
+ render: () => ,
+};
+
/**
* VRT only
*/
@@ -146,6 +196,121 @@ export const SizingFullWidth: Story = {
* Helpers
*/
+// Sentinels resolved to real CSS in `AdaptiveExample`: `EuiProvider` to the
+// live theme color mode (a module-level const can't read `useEuiTheme()`), and
+// `system` to `light dark` (the value that follows `prefers-color-scheme`).
+const PROVIDER_SCHEME = 'EuiProvider';
+const SYSTEM_SCHEME = 'system';
+
+const ADAPTIVE_COLOR_SCHEMES = [
+ { scheme: 'light', label: 'color-scheme: light' },
+ { scheme: 'dark', label: 'color-scheme: dark' },
+ { scheme: PROVIDER_SCHEME, label: 'color-scheme: EuiProvider' },
+ { scheme: SYSTEM_SCHEME, label: 'color-scheme: system' },
+] as const;
+
+const AdaptiveCard = ({
+ label,
+ illustration,
+ colorScheme,
+}: {
+ label: string;
+ illustration: EuiIllustrationSource;
+ colorScheme: string;
+}) => (
+
+
+ {label}
+
+
+
+
+);
+
+const AdaptiveExample = () => {
+ const { colorMode } = useEuiTheme();
+ const providerScheme = colorMode === 'DARK' ? 'dark' : 'light';
+
+ const resolveScheme = (scheme: string) => {
+ if (scheme === PROVIDER_SCHEME) return providerScheme;
+ if (scheme === SYSTEM_SCHEME) return 'light dark';
+ return scheme;
+ };
+ const resolveLabel = (scheme: string, label: string) => {
+ if (scheme === PROVIDER_SCHEME) return `${label} (${providerScheme})`;
+ if (scheme === SYSTEM_SCHEME) return `${label} (light dark)`;
+ return label;
+ };
+
+ return (
+
+
+
+
+ One shopping-cart.adaptive string, rendered under
+ several color-scheme values. No theme change or
+ re-render — CSS light-dark() does the work. The{' '}
+ EuiProvider card mirrors what{' '}
+ EuiIllustration does: it follows the EUI color mode
+ (toggle the theme in the Storybook toolbar). The system{' '}
+ card resolves to color-scheme: light dark, following
+ the OS/browser prefers-color-scheme instead, regardless
+ of the EUI theme.
+
+ aerospace has no adaptive variant, so it
+ falls back to the discrete light markup and does not
+ respond to color-scheme (shown following{' '}
+ EuiProvider).
+
+
+
+
+
+
+
+
+ );
+};
+
/**
* Fixture SVG for VRT. Uses a fixed width smaller than the parent container
* so VRT snapshots can verify sizing without depending on `@elastic/eui-illustrations`.
diff --git a/packages/eui/src/components/illustration/illustration.test.tsx b/packages/eui/src/components/illustration/illustration.test.tsx
index 2fc672c3c61..9edf6fdbc70 100644
--- a/packages/eui/src/components/illustration/illustration.test.tsx
+++ b/packages/eui/src/components/illustration/illustration.test.tsx
@@ -65,6 +65,51 @@ describe('EuiIllustration', () => {
});
});
+ describe('adaptive', () => {
+ const adaptiveIllustration: EuiIllustrationSource = {
+ ...illustration,
+ adaptive: '',
+ };
+
+ const originalCSS = global.CSS;
+ const stubCSS = (supported: boolean) => {
+ // jsdom's `CSS.supports` cannot evaluate `light-dark()`, so stub it.
+ global.CSS = { supports: () => supported } as unknown as typeof CSS;
+ };
+
+ beforeEach(() => stubCSS(true));
+ afterAll(() => {
+ global.CSS = originalCSS;
+ });
+
+ it('prefers the adaptive SVG when light-dark() is supported', () => {
+ const { container } = render(
+
+ );
+
+ expect(
+ container.querySelector('[data-mode="adaptive"]')
+ ).toBeInTheDocument();
+ expect(
+ container.querySelector('[data-mode="light"]')
+ ).not.toBeInTheDocument();
+ });
+
+ it('falls back to the discrete variant when light-dark() is unsupported', () => {
+ stubCSS(false);
+ const { container } = render(
+
+ );
+
+ expect(
+ container.querySelector('[data-mode="light"]')
+ ).toBeInTheDocument();
+ expect(
+ container.querySelector('[data-mode="adaptive"]')
+ ).not.toBeInTheDocument();
+ });
+ });
+
describe('accessibility', () => {
it('defaults the accessible label to the illustration title', () => {
const { container } = render();
diff --git a/packages/eui/src/components/illustration/illustration.tsx b/packages/eui/src/components/illustration/illustration.tsx
index 834341be097..1398fec08a8 100644
--- a/packages/eui/src/components/illustration/illustration.tsx
+++ b/packages/eui/src/components/illustration/illustration.tsx
@@ -33,6 +33,14 @@ export interface EuiIllustrationSource {
readonly light: string;
/** Trusted SVG markup for the dark color mode. Inlined verbatim — see the interface's security note. */
readonly dark: string;
+ /**
+ * Trusted single-SVG markup whose colors resolve via CSS `light-dark()`,
+ * driven by the `color-scheme` this component sets from the active
+ * `colorMode`. Preferred when present and supported; otherwise the component
+ * falls back to {@link light}/{@link dark}. Inlined verbatim — see the
+ * interface's security note.
+ */
+ readonly adaptive?: string;
}
export type EuiIllustrationProps = Omit<
@@ -58,11 +66,21 @@ export type EuiIllustrationProps = Omit<
fullWidth?: boolean;
};
+/**
+ * Whether the runtime can resolve CSS `light-dark()`. Defaults to `true` when
+ * `CSS` is unavailable (SSR) so the adaptive markup is chosen consistently on
+ * the server and on modern clients, avoiding a hydration mismatch.
+ */
+const supportsLightDark = () =>
+ typeof CSS === 'undefined' ||
+ (CSS.supports?.('color', 'light-dark(#000, #fff)') ?? true);
+
export const EuiIllustration: FunctionComponent = ({
type,
alt,
className,
fullWidth = true,
+ style,
...rest
}) => {
const { colorMode } = useEuiTheme();
@@ -70,7 +88,15 @@ export const EuiIllustration: FunctionComponent = ({
const classes = classNames('euiIllustration', className);
const cssStyles = [styles.euiIllustration, fullWidth && styles.fullWidth];
- const svg = colorMode === 'DARK' ? type.dark : type.light;
+ const isDark = colorMode === 'DARK';
+ const useAdaptive = type.adaptive != null && supportsLightDark();
+ const svg = useAdaptive ? type.adaptive! : isDark ? type.dark : type.light;
+
+ // Pins `color-scheme` so the adaptive SVG's `light-dark()` colors follow the
+ // EUI color mode rather than the OS preference.
+ const inlineStyle = useAdaptive
+ ? { colorScheme: isDark ? 'dark' : 'light', ...style }
+ : style;
const isDecorative = alt === '';
const a11yProps = isDecorative
@@ -81,6 +107,7 @@ export const EuiIllustration: FunctionComponent = ({
(light + dark)"] --> B["src/svgs name.light.svg + name.dark.svg"]
- B --> C["yarn generate (SVGO optimize + codegen)"]
- C --> D["src/generated/*.ts { id, title, light, dark }"]
- D --> E["build → lib/(cjs|esm) + types"]
- E --> F["EuiIllustration picks light/dark from useEuiTheme().colorMode"]
+ B --> C["yarn generate (SVGO optimize + merge)"]
+ C --> D["src/generated/*.ts { id, title, light, dark, adaptive? }"]
+ C --> G["src/generated/svgs/name.adaptive.svg"]
+ D --> E["build → lib/(cjs|esm) + types + lib/svgs"]
+ E --> F["EuiIllustration inlines adaptive and sets color-scheme from colorMode"]
```
Each generated module satisfies:
@@ -31,9 +31,24 @@ type EuiIllustrationSource = {
title: string; // 'Dashboard'
light: string; // optimized SVG markup
dark: string; // optimized SVG markup
+ adaptive?: string; // single SVG, colors via CSS light-dark()
};
```
+### Adaptive variant
+
+`generate` merges each `light`/`dark` pair into one SVG: colors that differ between the two are rewritten to `light-dark(, )` inline styles, shared colors are left as-is. The result adapts to the active [`color-scheme`](https://developer.mozilla.org/en-US/docs/Web/CSS/color-scheme) rather than requiring the consumer to swap files. `EuiIllustration` sets `color-scheme` from the EUI theme so the illustration follows the in-app toggle rather than the OS.
+
+Merging relies on the two files being structurally identical (same elements, only colors differ). When they diverge — different element counts or tag order — `adaptive` is skipped for that illustration (it keeps `light`/`dark` only) and `generate` logs which ones. Re-export both variants from the same artwork to enable adaptive output.
+
+The adaptive SVG is also written to `src/generated/svgs/.adaptive.svg` (published to `lib/svgs`) for `` / CSS-background consumers; that file pins `color-scheme: light dark` on its root so it follows the OS preference across an `` boundary:
+
+```tsx
+import dashboard from '@elastic/eui-illustrations/svgs/dashboard.adaptive.svg';
+
+;
+```
+
## Consuming
### With `@elastic/eui`
@@ -49,11 +64,18 @@ import { dashboard } from '@elastic/eui-illustrations';
### Without `@elastic/eui`
-The modules are plain data. Pick a mode and inline the markup yourself:
+The modules are plain data. Prefer `adaptive` (when present) and let CSS pick the color mode via `color-scheme`, or pick a discrete mode yourself:
```tsx
import { dashboard } from '@elastic/eui-illustrations';
+// Adaptive: one inlined SVG, colors follow the ancestor `color-scheme`.
+;
+
+// Or select a discrete variant.
const svg = isDarkMode ? dashboard.dark : dashboard.light;
;
```
diff --git a/packages/illustrations/changelogs/upcoming/adaptive_illustrations.md b/packages/illustrations/changelogs/upcoming/adaptive_illustrations.md
new file mode 100644
index 00000000000..87cc9988ed4
--- /dev/null
+++ b/packages/illustrations/changelogs/upcoming/adaptive_illustrations.md
@@ -0,0 +1 @@
+- Added an `adaptive` variant to illustration assets: a single SVG whose colors resolve at runtime via CSS `light-dark()`, generated by merging the `light`/`dark` pair. It is also emitted as `@elastic/eui-illustrations/svgs/.adaptive.svg` for ``/CSS consumers. Illustrations whose `light`/`dark` files are not structurally identical keep `light`/`dark` only.
diff --git a/packages/illustrations/package.json b/packages/illustrations/package.json
index 017e3e68c77..be14706d6a1 100644
--- a/packages/illustrations/package.json
+++ b/packages/illustrations/package.json
@@ -18,14 +18,16 @@
"import": "./lib/esm/index.js",
"require": "./lib/cjs/index.js",
"default": "./lib/cjs/index.js"
- }
+ },
+ "./svgs/*": "./lib/svgs/*"
},
"scripts": {
- "build": "yarn build:clean && yarn generate && yarn build:compile && yarn build:compile:esm && yarn build:types",
+ "build": "yarn build:clean && yarn generate && yarn build:compile && yarn build:compile:esm && yarn build:types && yarn build:svgs",
"build-pack": "yarn build && npm pack",
"build:clean": "rimraf dist/ lib/",
"build:compile": "NODE_ENV=production babel src --out-dir=lib/cjs --extensions .ts --ignore \"**/*.test.ts\"",
"build:compile:esm": "tsc --project ./tsconfig.esm.json",
+ "build:svgs": "node -e \"require('fs').cpSync('src/generated/svgs','lib/svgs',{recursive:true,force:true})\"",
"build:types": "NODE_ENV=production tsc --project tsconfig.types.json",
"generate": "node scripts/generate.js",
"lint": "yarn generate && tsc --noEmit",
diff --git a/packages/illustrations/scripts/generate.js b/packages/illustrations/scripts/generate.js
index ef66b1db833..877220fb089 100644
--- a/packages/illustrations/scripts/generate.js
+++ b/packages/illustrations/scripts/generate.js
@@ -27,6 +27,7 @@ const { optimize } = require('svgo');
const svgsDir = path.resolve(__dirname, '../src/svgs');
const outputDir = path.resolve(__dirname, '../src/generated');
+const svgOutputDir = path.join(outputDir, 'svgs');
const SVG_FILE = /^(.+)\.(light|dark)\.svg$/;
@@ -38,6 +39,31 @@ const svgoConfig = {
],
};
+/**
+ * SVGO can rewrite `light-dark()` inline styles (via `minifyStyles`/csso), so it
+ * is disabled for the adaptive pass. The rest of `preset-default` still runs.
+ */
+const adaptiveSvgoConfig = {
+ multipass: true,
+ plugins: [
+ {
+ name: 'preset-default',
+ params: { overrides: { removeViewBox: false, minifyStyles: false } },
+ },
+ 'removeDimensions',
+ ],
+};
+
+/** CSS color-bearing properties paired between the light and dark variants. */
+const COLOR_PROPS = [
+ 'fill',
+ 'stroke',
+ 'stop-color',
+ 'flood-color',
+ 'lighting-color',
+ 'color',
+];
+
/** `data-viz`/`data_viz` -> `dataViz` for a valid JS identifier. */
const toCamelCase = (name) =>
name.replace(/[-_](.)/g, (_, char) => char.toUpperCase());
@@ -61,6 +87,140 @@ const optimizeSvg = (filePath) => {
return data;
};
+/** Parse an SVG into SVGO's XAST without applying any transforms. */
+const parseSvg = (raw) => {
+ let root;
+ optimize(raw, {
+ plugins: [{ name: 'capture-ast', fn: (parsed) => ((root = parsed), {}) }],
+ });
+ return root;
+};
+
+/** Depth-first, pre-order list of element nodes. */
+const collectElements = (node, acc = []) => {
+ if (node.type === 'element') acc.push(node);
+ for (const child of node.children ?? []) collectElements(child, acc);
+ return acc;
+};
+
+const parseStyle = (value) => {
+ const declarations = new Map();
+ for (const declaration of (value ?? '').split(';')) {
+ const separator = declaration.indexOf(':');
+ if (separator === -1) continue;
+ const prop = declaration.slice(0, separator).trim();
+ if (prop) declarations.set(prop, declaration.slice(separator + 1).trim());
+ }
+ return declarations;
+};
+
+const serializeStyle = (declarations) =>
+ Array.from(declarations, ([prop, value]) => `${prop}:${value}`).join(';');
+
+const normalizeColor = (value) =>
+ value == null ? value : value.trim().toLowerCase();
+
+/**
+ * Pairs light elements to dark elements positionally. Same-artwork exports keep
+ * a stable element order, so a matching tag-name sequence is a reliable signal
+ * that colors can be paired 1:1 (coordinates may drift between re-exports).
+ * Returns `null` when the trees diverge (different element count or tag
+ * sequence) — the signal that the pair cannot be safely auto-merged and
+ * adaptive output should be skipped in favor of the discrete light/dark pair.
+ */
+const pairElements = (lightElements, darkElements) => {
+ if (lightElements.length !== darkElements.length) return null;
+
+ const pairs = [];
+ for (let index = 0; index < lightElements.length; index++) {
+ const lightNode = lightElements[index];
+ const darkNode = darkElements[index];
+ if (lightNode.name !== darkNode.name) return null;
+ pairs.push([lightNode, darkNode]);
+ }
+ return pairs;
+};
+
+const colorValue = (node, prop, style) =>
+ node.attributes[prop] ?? style.get(prop);
+
+/**
+ * Rewrites every color that differs between the light and dark variants into a
+ * `light-dark(light, dark)` inline style on the light tree, leaving shared
+ * colors untouched. Mutates `lightRoot`. Returns `false` (leaving the tree
+ * untouched) when the pair cannot be safely merged.
+ */
+const mergeColorModes = (lightRoot, darkRoot) => {
+ const pairs = pairElements(
+ collectElements(lightRoot),
+ collectElements(darkRoot)
+ );
+ if (!pairs) return false;
+
+ for (const [lightNode, darkNode] of pairs) {
+ const lightStyle = parseStyle(lightNode.attributes.style);
+ const darkStyle = parseStyle(darkNode.attributes.style);
+ let changed = false;
+
+ for (const prop of COLOR_PROPS) {
+ const lightValue = colorValue(lightNode, prop, lightStyle);
+ const darkValue = colorValue(darkNode, prop, darkStyle);
+ if (lightValue == null || darkValue == null) continue;
+ // Idempotent: never re-wrap an already-merged value into `light-dark()`.
+ if (/^light-dark\(/i.test(lightValue.trim())) continue;
+ if (normalizeColor(lightValue) === normalizeColor(darkValue)) continue;
+
+ lightStyle.set(prop, `light-dark(${lightValue}, ${darkValue})`);
+ delete lightNode.attributes[prop];
+ changed = true;
+ }
+
+ if (changed) lightNode.attributes.style = serializeStyle(lightStyle);
+ }
+ return true;
+};
+
+/**
+ * Merges a light/dark pair into a single optimized SVG whose colors respond to
+ * the active `color-scheme` via `light-dark()`. Returns `null` when the pair
+ * cannot be safely merged (see {@link pairElements}).
+ *
+ * The inline flavor omits `color-scheme` so the container controls it (e.g.
+ * `EuiIllustration` from the EUI theme). The file flavor pins
+ * `color-scheme: light dark` on the root so ``/CSS-background consumers
+ * follow the OS preference, which cannot be reached across an `` boundary.
+ */
+const buildAdaptiveSvg = (lightPath, darkPath) => {
+ const darkRoot = parseSvg(fs.readFileSync(darkPath, 'utf8'));
+ let merged = true;
+
+ // Merge colors in a single pass. The merge plugin is not idempotent under
+ // SVGO's `multipass`, so it must not run in the optimizing pass below.
+ const { data: mergedSvg } = optimize(fs.readFileSync(lightPath, 'utf8'), {
+ multipass: false,
+ path: lightPath,
+ plugins: [
+ {
+ name: 'merge-color-modes',
+ fn: (root) => ((merged = mergeColorModes(root, darkRoot)), {}),
+ },
+ ],
+ });
+
+ if (!merged) return null;
+
+ const { data: inline } = optimize(mergedSvg, {
+ ...adaptiveSvgoConfig,
+ path: lightPath,
+ });
+
+ const file = inline.replace(/^(