diff --git a/docs/6.x/docs/guides/theming.mdx b/docs/6.x/docs/guides/theming.mdx
index 58836dc167..bb9ab156c5 100644
--- a/docs/6.x/docs/guides/theming.mdx
+++ b/docs/6.x/docs/guides/theming.mdx
@@ -65,6 +65,7 @@ You can change the theme prop dynamically and all the components will automatica
A theme usually contains the following properties:
- `dark` (`boolean`): whether this is a dark theme or light theme.
+- `contrast` (`'standard' | 'medium' | 'high'`): the active MD3 contrast level (see [Contrast levels](#contrast-levels)).
- `version`: Material You (MD3); kept for compatibility and normalized to `3` by `PaperProvider`
- `mode` (`'adaptive' | 'exact'`): color mode for dark theme (See [Dark Theme](#dark-theme)).
- `roundness` (`number`): roundness of common elements, such as buttons.
@@ -244,6 +245,62 @@ export default function Main() {
}
```
+## Contrast levels
+
+Material Design 3 defines three contrast levels - `standard`, `medium` and `high`. The higher levels increase the contrast between foreground and background roles, which helps users with low vision and improves readability in bright environments.
+
+Set the level with the `contrast` prop on `PaperProvider`. It defaults to `standard`, so existing apps are unaffected.
+
+```js
+import * as React from 'react';
+import { PaperProvider } from 'react-native-paper';
+
+export default function Main() {
+ return (
+
+
+
+ );
+}
+```
+
+Use the `contrast` prop rather than passing a pre-built theme: `PaperProvider` only follows the system light/dark setting while no `theme` prop is given, so selecting contrast through `theme` would also opt you out of automatic dark mode.
+
+The `medium` and `high` schemes meet the WCAG contrast ratios of 4.5:1 and 7:1 respectively for every foreground/background role pair.
+
+The active level is readable from the theme:
+
+```js
+const { contrast } = useTheme();
+```
+
+To build a theme object directly, for example to hand to `adaptNavigationTheme`, use `createTheme` or `getTheme`:
+
+```js
+import { createTheme, getTheme } from 'react-native-paper';
+
+const highContrastDark = createTheme({ dark: true, contrast: 'high' });
+const sameThing = getTheme(true, 'high');
+```
+
+### Contrast and dynamic colors
+
+Android does not expose a contrast-adjusted version of its system palette. Applying the standard-contrast system colors at a raised contrast level would quietly undercut the level you asked for, so at `medium` and `high` the dynamic palette is skipped in favour of the contrast-correct scheme.
+
+`getDynamicTheme` applies this rule for you, and `isDynamicColorSupportedAtContrast` reports whether dynamic colors will actually be used:
+
+```js
+import {
+ getDynamicTheme,
+ isDynamicColorSupportedAtContrast,
+} from 'react-native-paper';
+
+// Falls back to the high-contrast scheme, dynamic colors included only at 'standard'.
+const theme = getDynamicTheme(isDarkMode, 'high');
+
+isDynamicColorSupportedAtContrast('high'); // false
+```
+
## Adapting React Navigation theme
The `adaptNavigationTheme` function takes an existing React Navigation theme and returns a React Navigation theme using the colors from Material Design 3. This theme can be passed to `NavigationContainer` so that React Navigation's UI elements have the same color scheme as Paper.
diff --git a/example/src/DrawerItems.tsx b/example/src/DrawerItems.tsx
index 94afa3136c..3101f58634 100644
--- a/example/src/DrawerItems.tsx
+++ b/example/src/DrawerItems.tsx
@@ -11,6 +11,7 @@ import {
Drawer,
Palette,
Portal,
+ SegmentedButtons,
Switch,
Text,
TouchableRipple,
@@ -105,9 +106,11 @@ function DrawerItems() {
toggleCollapsed,
toggleCustomFont,
toggleRippleEffect,
+ setContrast,
customFontLoaded,
rippleEffectEnabled,
collapsed,
+ contrast,
rtl: isRTL,
theme: { dark: isDarkTheme },
shouldUseDynamicTheme,
@@ -192,6 +195,20 @@ function DrawerItems() {
+
+ Contrast
+ setContrast(value)}
+ density="small"
+ buttons={[
+ { value: 'standard', label: 'Standard' },
+ { value: 'medium', label: 'Medium' },
+ { value: 'high', label: 'High' },
+ ]}
+ />
+
+
RTL
@@ -278,6 +295,12 @@ const styles = StyleSheet.create({
height: 56,
paddingHorizontal: 28,
},
+ contrastPreference: {
+ flexDirection: 'column',
+ alignItems: 'stretch',
+ gap: 12,
+ paddingHorizontal: 28,
+ },
badge: {
alignSelf: 'center',
},
diff --git a/example/src/PreferencesContext.tsx b/example/src/PreferencesContext.tsx
index b5e381ae05..875fa8e4e9 100644
--- a/example/src/PreferencesContext.tsx
+++ b/example/src/PreferencesContext.tsx
@@ -1,6 +1,6 @@
import * as React from 'react';
-import type { Theme } from 'react-native-paper';
+import type { ContrastLevel, Theme } from 'react-native-paper';
export const PreferencesContext = React.createContext<{
toggleTheme: () => void;
@@ -9,7 +9,9 @@ export const PreferencesContext = React.createContext<{
toggleCustomFont: () => void;
toggleRippleEffect: () => void;
toggleShouldUseDynamicTheme?: () => void;
+ setContrast: (contrast: ContrastLevel) => void;
theme: Theme;
+ contrast: ContrastLevel;
rtl: boolean;
collapsed: boolean;
customFontLoaded: boolean;
diff --git a/example/src/index.tsx b/example/src/index.tsx
index afa3941044..47ad19db06 100644
--- a/example/src/index.tsx
+++ b/example/src/index.tsx
@@ -14,10 +14,9 @@ import { StatusBar } from 'expo-status-bar';
import * as Updates from 'expo-updates';
import {
PaperProvider,
- DarkTheme,
- LightTheme,
- DynamicLightTheme,
- DynamicDarkTheme,
+ createTheme,
+ getDynamicTheme,
+ type ContrastLevel,
} from 'react-native-paper';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
@@ -26,8 +25,7 @@ import { PreferencesContext } from './PreferencesContext';
import App from './RootNavigator';
import { dynamicThemeSupported } from '../utils';
import {
- CombinedDarkTheme,
- CombinedDefaultTheme,
+ createCombinedTheme,
createConfiguredFontNavigationTheme,
createConfiguredFontTheme,
} from '../utils/themes';
@@ -98,15 +96,12 @@ export default function PaperExample() {
const [collapsed, setCollapsed] = React.useState(false);
const [customFontLoaded, setCustomFont] = React.useState(false);
const [rippleEffectEnabled, setRippleEffectEnabled] = React.useState(true);
+ const [contrast, setContrast] = React.useState('standard');
const theme =
dynamicThemeSupported && shouldUseDynamicTheme
- ? isDarkMode
- ? DynamicDarkTheme
- : DynamicLightTheme
- : isDarkMode
- ? DarkTheme
- : LightTheme;
+ ? getDynamicTheme(isDarkMode, contrast)
+ : createTheme({ dark: isDarkMode, contrast });
const direction = rtl ? 'rtl' : 'ltr';
@@ -122,6 +117,13 @@ export default function PaperExample() {
if (typeof preferences.rtl === 'boolean') {
setRtl(preferences.rtl);
}
+
+ if (
+ preferences.contrast === 'medium' ||
+ preferences.contrast === 'high'
+ ) {
+ setContrast(preferences.contrast);
+ }
}
} catch (e) {
// ignore error
@@ -145,6 +147,7 @@ export default function PaperExample() {
JSON.stringify({
theme: isDarkMode ? 'dark' : 'light',
rtl,
+ contrast,
})
);
} catch (e) {
@@ -165,7 +168,7 @@ export default function PaperExample() {
};
void savePrefs();
- }, [direction, isDarkMode, isReady, rtl]);
+ }, [contrast, direction, isDarkMode, isReady, rtl]);
const preferences = React.useMemo(
() => ({
@@ -176,9 +179,11 @@ export default function PaperExample() {
toggleCollapsed: () => setCollapsed((oldValue) => !oldValue),
toggleCustomFont: () => setCustomFont((oldValue) => !oldValue),
toggleRippleEffect: () => setRippleEffectEnabled((oldValue) => !oldValue),
+ setContrast,
customFontLoaded,
rippleEffectEnabled,
shouldUseDynamicTheme,
+ contrast,
theme,
collapsed,
rtl,
@@ -187,6 +192,7 @@ export default function PaperExample() {
rtl,
theme,
collapsed,
+ contrast,
customFontLoaded,
shouldUseDynamicTheme,
rippleEffectEnabled,
@@ -197,7 +203,7 @@ export default function PaperExample() {
return null;
}
- const combinedTheme = isDarkMode ? CombinedDarkTheme : CombinedDefaultTheme;
+ const combinedTheme = createCombinedTheme(theme, isDarkMode);
const configuredFontTheme = createConfiguredFontTheme(combinedTheme);
const configuredFontNavigationTheme =
createConfiguredFontNavigationTheme(combinedTheme);
diff --git a/example/utils/themes.ts b/example/utils/themes.ts
index 22fda309e7..6ce2ce059f 100644
--- a/example/utils/themes.ts
+++ b/example/utils/themes.ts
@@ -3,44 +3,38 @@ import {
DefaultTheme as NavigationDefaultTheme,
} from '@react-navigation/native';
import type { Theme as ReactNavigationTheme } from '@react-navigation/native';
-import {
- adaptNavigationTheme,
- DarkTheme,
- LightTheme,
- configureFonts,
-} from 'react-native-paper';
+import { adaptNavigationTheme, configureFonts } from 'react-native-paper';
import type { Theme } from 'react-native-paper';
-const { LightTheme: NavLightTheme, DarkTheme: NavDarkTheme } =
- adaptNavigationTheme({
- reactNavigationLight: NavigationDefaultTheme,
- reactNavigationDark: NavigationDarkTheme,
- });
+/**
+ * Merges the React Navigation theme into a Paper theme.
+ *
+ * The Paper theme is passed in, and also given to `adaptNavigationTheme`, so
+ * that the selected contrast level is kept.
+ */
+export const createCombinedTheme = (paperTheme: Theme, isDark: boolean) => {
+ const { LightTheme: NavLightTheme, DarkTheme: NavDarkTheme } =
+ adaptNavigationTheme({
+ reactNavigationLight: NavigationDefaultTheme,
+ reactNavigationDark: NavigationDarkTheme,
+ materialLight: isDark ? undefined : paperTheme,
+ materialDark: isDark ? paperTheme : undefined,
+ });
-export const CombinedDefaultTheme = {
- ...LightTheme,
- ...NavLightTheme,
- colors: {
- ...LightTheme.colors,
- ...NavLightTheme.colors,
- },
- fonts: {
- ...LightTheme.fonts,
- ...NavLightTheme.fonts,
- },
-};
+ const navTheme = isDark ? NavDarkTheme : NavLightTheme;
-export const CombinedDarkTheme = {
- ...DarkTheme,
- ...NavDarkTheme,
- colors: {
- ...DarkTheme.colors,
- ...NavDarkTheme.colors,
- },
- fonts: {
- ...DarkTheme.fonts,
- ...NavDarkTheme.fonts,
- },
+ return {
+ ...paperTheme,
+ ...navTheme,
+ colors: {
+ ...paperTheme.colors,
+ ...navTheme.colors,
+ },
+ fonts: {
+ ...paperTheme.fonts,
+ ...navTheme.fonts,
+ },
+ };
};
export const createConfiguredFontTheme = (
diff --git a/package.json b/package.json
index 6db11f9fef..af16cdb4a7 100644
--- a/package.json
+++ b/package.json
@@ -40,6 +40,7 @@
"test": "jest",
"prepack": "bob build",
"generate-mappings": "node ./scripts/generate-mappings.ts",
+ "generate-contrast-tokens": "node ./scripts/generate-contrast-tokens.ts",
"release": "release-it --only-version",
"docs": "yarn --cwd docs",
"example": "yarn --cwd example"
@@ -61,6 +62,7 @@
"@commitlint/config-conventional": "^8.3.4",
"@eslint/js": "9.39.4",
"@jest/globals": "^29.7.0",
+ "@material/material-color-utilities": "0.3.0",
"@react-native-vector-icons/material-design-icons": "^12.0.0",
"@react-native/babel-preset": "^0.85.3",
"@react-native/jest-preset": "^0.85.3",
diff --git a/scripts/generate-contrast-tokens.ts b/scripts/generate-contrast-tokens.ts
new file mode 100644
index 0000000000..dd5414a4c7
--- /dev/null
+++ b/scripts/generate-contrast-tokens.ts
@@ -0,0 +1,207 @@
+/**
+ * Generates the MD3 medium and high contrast schemes into
+ * `src/theme/tokens/sys/contrastSchemes.ts`.
+ *
+ * MD3 does not pick a different palette step for medium and high contrast.
+ * For each role it looks for the tone that hits a target contrast ratio
+ * against that role's background. The tones it finds are usually not whole numbers.
+ * For example `primary` at light/high lands on tone 13.3, so it cannot be
+ * written as a key of `ref/palette.ts`.
+ *
+ * Run with `yarn generate-contrast-tokens`.
+ */
+import {
+ Hct,
+ MaterialDynamicColors,
+ SchemeTonalSpot,
+ argbFromHex,
+ blueFromArgb,
+ greenFromArgb,
+ redFromArgb,
+} from '@material/material-color-utilities';
+import { writeFileSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import type { ElevationColors, ThemeColors } from '../src/theme/types';
+
+type Role = Exclude;
+
+type ElevationKey = Exclude;
+
+const SEED = '#6750A4';
+
+const MODES = [
+ ['light', false],
+ ['dark', true],
+] as const;
+
+const CONTRASTS = [
+ ['medium', 0.5],
+ ['high', 1.0],
+] as const;
+
+// The roles to generate, in the order they are written to the output file.
+const ROLES = [
+ 'primary',
+ 'primaryContainer',
+ 'secondary',
+ 'secondaryContainer',
+ 'tertiary',
+ 'tertiaryContainer',
+ 'surface',
+ 'surfaceDim',
+ 'surfaceBright',
+ 'surfaceContainerLowest',
+ 'surfaceContainerLow',
+ 'surfaceContainer',
+ 'surfaceContainerHigh',
+ 'surfaceContainerHighest',
+ 'surfaceVariant',
+ 'background',
+ 'error',
+ 'errorContainer',
+ 'onPrimary',
+ 'onPrimaryContainer',
+ 'onSecondary',
+ 'onSecondaryContainer',
+ 'onTertiary',
+ 'onTertiaryContainer',
+ 'onSurface',
+ 'onSurfaceVariant',
+ 'onError',
+ 'onErrorContainer',
+ 'onBackground',
+ 'outline',
+ 'outlineVariant',
+ 'inverseSurface',
+ 'inverseOnSurface',
+ 'inversePrimary',
+ 'primaryFixed',
+ 'primaryFixedDim',
+ 'onPrimaryFixed',
+ 'onPrimaryFixedVariant',
+ 'secondaryFixed',
+ 'secondaryFixedDim',
+ 'onSecondaryFixed',
+ 'onSecondaryFixedVariant',
+ 'tertiaryFixed',
+ 'tertiaryFixedDim',
+ 'onTertiaryFixed',
+ 'onTertiaryFixedVariant',
+ 'shadow',
+ 'scrim',
+] as const satisfies readonly Role[];
+
+// Fails to compile if a role in `ThemeColors` is missing from `ROLES`.
+type MissingRoles = Exclude;
+
+type AssertNoMissingRoles = T;
+
+export type _RolesAreComplete = AssertNoMissingRoles;
+
+/**
+ * Tonal elevation surfaces. MD3 maps these onto the surface container roles.
+ * `level0` is always transparent, so it is not generated.
+ */
+const ELEVATION_ROLES: Record = {
+ level1: 'surfaceContainerLow',
+ level2: 'surfaceContainer',
+ level3: 'surfaceContainerHigh',
+ level4: 'surfaceContainerHigh',
+ level5: 'surfaceContainerHighest',
+};
+
+/** Matches the `rgba(r, g, b, 1)` format used throughout the theme tokens. */
+const toRgbaString = (argb: number) =>
+ `rgba(${redFromArgb(argb)}, ${greenFromArgb(argb)}, ${blueFromArgb(argb)}, 1)`;
+
+const lookupRole = (role: string): unknown =>
+ Object.entries(MaterialDynamicColors).find(([key]) => key === role)?.[1];
+
+const resolveRole = (scheme: SchemeTonalSpot, role: string) => {
+ const dynamicColor = lookupRole(role);
+
+ if (
+ dynamicColor == null ||
+ typeof dynamicColor !== 'object' ||
+ !('getArgb' in dynamicColor) ||
+ typeof dynamicColor.getArgb !== 'function'
+ ) {
+ throw new Error(
+ `@material/material-color-utilities does not expose the "${role}" role. ` +
+ `Check the pinned version, role coverage differs between releases.`
+ );
+ }
+
+ return toRgbaString(dynamicColor.getArgb(scheme));
+};
+
+const seed = Hct.fromInt(argbFromHex(SEED));
+
+const schemes = MODES.map(([mode, isDark]) => {
+ const contrasts = CONTRASTS.map(([contrast, level]) => {
+ const scheme = new SchemeTonalSpot(seed, isDark, level);
+
+ const roles = ROLES.map(
+ (role) => ` ${role}: '${resolveRole(scheme, role)}',`
+ ).join('\n');
+
+ const elevation = Object.entries(ELEVATION_ROLES)
+ .map(([key, role]) => ` ${key}: '${resolveRole(scheme, role)}',`)
+ .join('\n');
+
+ return [
+ ` ${contrast}: {`,
+ ` roles: {`,
+ roles,
+ ` },`,
+ ` elevation: {`,
+ elevation,
+ ` },`,
+ ` },`,
+ ].join('\n');
+ }).join('\n');
+
+ return [` ${mode}: {`, contrasts, ` },`].join('\n');
+}).join('\n');
+
+const output = `/**
+ * GENERATED by scripts/generate-contrast-tokens.ts, do not edit by hand.
+ * Run \`yarn generate-contrast-tokens\` to regenerate.
+ *
+ * MD3 medium and high contrast schemes, seeded from ${SEED}.
+ *
+ */
+import type { ContrastLevel, ElevationColors, ThemeColors } from '../../types';
+
+type GeneratedContrast = Exclude;
+
+type GeneratedScheme = {
+ roles: Record<
+ Exclude,
+ string
+ >;
+ elevation: Record, string>;
+};
+
+export const contrastSchemes: Record<
+ 'light' | 'dark',
+ Record
+> = {
+${schemes}
+} as const;
+`;
+
+const scriptDir = dirname(fileURLToPath(import.meta.url));
+const outputPath = resolve(
+ scriptDir,
+ '..',
+ 'src/theme/tokens/sys/contrastSchemes.ts'
+);
+
+writeFileSync(outputPath, output);
+
+console.log(
+ `Generated ${ROLES.length} roles x ${MODES.length} modes x ${CONTRASTS.length} contrast levels -> ${outputPath}`
+);
diff --git a/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap b/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap
index 7e7dcc158c..f42d41e0ef 100644
--- a/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap
+++ b/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap
@@ -74,6 +74,7 @@ exports[`renders list section with custom title style 1`] = `
"tertiaryFixed": "rgba(255, 216, 228, 1)",
"tertiaryFixedDim": "rgba(239, 184, 200, 1)",
},
+ "contrast": "standard",
"dark": false,
"elevation": {
"level0": 0,
@@ -836,6 +837,7 @@ exports[`renders list section with subheader 1`] = `
"tertiaryFixed": "rgba(255, 216, 228, 1)",
"tertiaryFixedDim": "rgba(239, 184, 200, 1)",
},
+ "contrast": "standard",
"dark": false,
"elevation": {
"level0": 0,
@@ -1596,6 +1598,7 @@ exports[`renders list section without subheader 1`] = `
"tertiaryFixed": "rgba(255, 216, 228, 1)",
"tertiaryFixedDim": "rgba(239, 184, 200, 1)",
},
+ "contrast": "standard",
"dark": false,
"elevation": {
"level0": 0,
diff --git a/src/core/PaperProvider.tsx b/src/core/PaperProvider.tsx
index 5149efcd6f..14df4b0f42 100644
--- a/src/core/PaperProvider.tsx
+++ b/src/core/PaperProvider.tsx
@@ -4,7 +4,7 @@ import { getDefaultDirection, LocaleProvider, type Direction } from './locale';
import SafeAreaProviderCompat from './SafeAreaProviderCompat';
import { Provider as SettingsProvider } from './settings';
import type { Settings } from './settings';
-import { defaultThemes, ThemeProvider } from './theming';
+import { getTheme, ThemeProvider } from './theming';
import {
useResolvedReduceMotion,
type ReduceMotionPreference,
@@ -13,7 +13,7 @@ import { useSystemColorScheme } from './useSystemColorScheme';
import MaterialCommunityIcon from '../components/MaterialCommunityIcon';
import PortalHost from '../components/Portal/PortalHost';
import { ReduceMotionContext } from '../theme/accessibility/ReduceMotionContext';
-import type { ThemeProp } from '../types';
+import type { ContrastLevel, ThemeProp } from '../types';
export type Props = {
children: React.ReactNode;
@@ -21,17 +21,19 @@ export type Props = {
settings?: Settings;
direction?: Direction;
reduceMotion?: ReduceMotionPreference;
+ contrast?: ContrastLevel;
};
const PaperProvider = (props: Props) => {
- const { reduceMotion = 'auto' } = props;
+ const { reduceMotion = 'auto', contrast } = props;
const colorScheme = useSystemColorScheme(!props.theme);
const resolvedReduceMotion = useResolvedReduceMotion(reduceMotion);
const theme = React.useMemo(() => {
const isDark = props.theme?.dark ?? colorScheme === 'dark';
- const base = defaultThemes[isDark ? 'dark' : 'light'];
+ const level = contrast ?? props.theme?.contrast ?? 'standard';
+ const base = getTheme(isDark, level);
const scale = resolvedReduceMotion
? 0
: (props.theme?.animation?.scale ?? 1);
@@ -39,10 +41,11 @@ const PaperProvider = (props: Props) => {
return {
...base,
...props.theme,
+ contrast: level,
colors: { ...base.colors, ...props.theme?.colors },
animation: { ...props.theme?.animation, scale },
};
- }, [colorScheme, props.theme, resolvedReduceMotion]);
+ }, [colorScheme, contrast, props.theme, resolvedReduceMotion]);
const { children, settings } = props;
diff --git a/src/core/__tests__/PaperProvider.test.tsx b/src/core/__tests__/PaperProvider.test.tsx
index 28ac30e327..43ea7c7175 100644
--- a/src/core/__tests__/PaperProvider.test.tsx
+++ b/src/core/__tests__/PaperProvider.test.tsx
@@ -14,7 +14,7 @@ import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext';
import { DarkTheme, DynamicLightTheme, LightTheme } from '../../theme/schemes';
import type { ThemeProp } from '../../types';
import PaperProvider from '../PaperProvider';
-import { useTheme } from '../theming';
+import { getTheme, useTheme } from '../theming';
declare module 'react-native' {
interface AccessibilityInfoStatic {
@@ -329,4 +329,70 @@ describe('PaperProvider', () => {
customTheme
);
});
+
+ it('applies the contrast prop without a theme prop', async () => {
+ mockAppearance();
+ await render(
+
+
+
+ );
+
+ // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion.
+ const theme = screen.getByTestId('provider-child-view').props.theme;
+
+ expect(theme).toStrictEqual(getTheme(false, 'high'));
+ expect(theme.contrast).toBe('high');
+ expect(theme.colors.primary).not.toBe(LightTheme.colors.primary);
+ });
+
+ it('keeps following the system color scheme when only contrast is set', async () => {
+ mockAppearance();
+ await render(
+
+
+
+ );
+
+ expect(
+ // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion.
+ screen.getByTestId('provider-child-view').props.theme
+ ).toStrictEqual(getTheme(false, 'medium'));
+
+ await act(() => Appearance.__internalListeners[0]({ colorScheme: 'dark' }));
+
+ expect(
+ // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion.
+ screen.getByTestId('provider-child-view').props.theme
+ ).toStrictEqual(getTheme(true, 'medium'));
+ });
+
+ it('defaults to standard contrast', async () => {
+ mockAppearance();
+ await render(createProvider());
+
+ expect(
+ // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion.
+ screen.getByTestId('provider-child-view').props.theme.contrast
+ ).toBe('standard');
+ });
+
+ it('lets the contrast prop win over a theme declaring its own level', async () => {
+ mockAppearance();
+ await render(
+
+
+
+ );
+
+ // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion.
+ const theme = screen.getByTestId('provider-child-view').props.theme;
+
+ expect(theme.contrast).toBe('standard');
+ expect(theme.colors.primary).toBe(LightTheme.colors.primary);
+ });
});
diff --git a/src/core/__tests__/theming.test.tsx b/src/core/__tests__/theming.test.tsx
index cb61aef20d..b5b8cad0c3 100644
--- a/src/core/__tests__/theming.test.tsx
+++ b/src/core/__tests__/theming.test.tsx
@@ -1,7 +1,7 @@
import { describe, expect, it } from '@jest/globals';
import { DarkTheme, LightTheme } from '../../theme/schemes';
-import { adaptNavigationTheme } from '../theming';
+import { adaptNavigationTheme, getTheme } from '../theming';
const NavigationLightTheme = {
dark: false,
@@ -273,4 +273,25 @@ describe('adaptNavigationTheme', () => {
expect(navLight).not.toHaveProperty('fonts');
expect(navDark).not.toHaveProperty('fonts');
});
+
+ it('adapts the colors of a raised-contrast material theme', () => {
+ const materialLight = getTheme(false, 'high');
+ const materialDark = getTheme(true, 'high');
+
+ const { LightTheme: navLight, DarkTheme: navDark } = adaptNavigationTheme({
+ reactNavigationLight: NavigationLightTheme,
+ reactNavigationDark: NavigationDarkTheme,
+ materialLight,
+ materialDark,
+ });
+
+ // Apps spread the navigation colors over the Paper theme, so these
+ // must match the contrast level that was asked for.
+ expect(navLight.colors.primary).toBe(materialLight.colors.primary);
+ expect(navLight.colors.text).toBe(materialLight.colors.onSurface);
+ expect(navDark.colors.primary).toBe(materialDark.colors.primary);
+
+ expect(navLight.colors.primary).not.toBe(LightTheme.colors.primary);
+ expect(navDark.colors.primary).not.toBe(DarkTheme.colors.primary);
+ });
});
diff --git a/src/index.tsx b/src/index.tsx
index 8863e2fa20..9916276c41 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -5,6 +5,7 @@ export {
withTheme,
ThemeProvider,
adaptNavigationTheme,
+ getTheme,
} from './core/theming';
export { useLocale, LocaleProvider } from './core/locale';
@@ -147,4 +148,9 @@ export type { Props as SegmentedButtonsProps } from './components/SegmentedButto
export type { Props as ListImageProps } from './components/List/ListImage';
export type { Props as TooltipProps } from './components/Tooltip/Tooltip';
-export { type TypescaleKey, type Theme, type Elevation } from './types';
+export {
+ type TypescaleKey,
+ type Theme,
+ type Elevation,
+ type ContrastLevel,
+} from './types';
diff --git a/src/theme/__tests__/contrast.test.ts b/src/theme/__tests__/contrast.test.ts
new file mode 100644
index 0000000000..3b3fbb92ac
--- /dev/null
+++ b/src/theme/__tests__/contrast.test.ts
@@ -0,0 +1,132 @@
+import { describe, expect, it } from '@jest/globals';
+import color from 'color';
+
+import { createTheme } from '../schemes/createTheme';
+import { DarkTheme } from '../schemes/DarkTheme';
+import { LightTheme } from '../schemes/LightTheme';
+import { palette } from '../tokens/ref/palette';
+import { buildScheme } from '../tokens/sys/color';
+import type { ContrastLevel, ThemeColors } from '../types';
+
+const MODES = ['light', 'dark'] as const;
+const NON_STANDARD = ['medium', 'high'] as const satisfies ContrastLevel[];
+
+/**
+ * Text and background role pairs that MD3 requires to be readable.
+ * @see https://m3.material.io/styles/color/roles
+ */
+const CONTRAST_PAIRS: [keyof ThemeColors, keyof ThemeColors][] = [
+ ['onPrimary', 'primary'],
+ ['onPrimaryContainer', 'primaryContainer'],
+ ['onSecondary', 'secondary'],
+ ['onSecondaryContainer', 'secondaryContainer'],
+ ['onTertiary', 'tertiary'],
+ ['onTertiaryContainer', 'tertiaryContainer'],
+ ['onError', 'error'],
+ ['onErrorContainer', 'errorContainer'],
+ ['onSurface', 'surface'],
+ ['onSurfaceVariant', 'surfaceVariant'],
+ ['onBackground', 'background'],
+ ['onSurface', 'surfaceContainer'],
+ ['onSurface', 'surfaceContainerHighest'],
+ ['inverseOnSurface', 'inverseSurface'],
+ ['onPrimaryFixed', 'primaryFixed'],
+ ['onSecondaryFixed', 'secondaryFixed'],
+ ['onTertiaryFixed', 'tertiaryFixed'],
+];
+
+/** WCAG 2.x minimum ratio per MD3 contrast level. */
+const WCAG_TARGET: Record, number> = {
+ medium: 4.5,
+ high: 7,
+};
+
+/** Theme colors are typed as `ColorValue`, but every built-in scheme uses an
+ * `rgba()` string. Anything else means the scheme is broken. */
+const asColor = (value: unknown) => {
+ if (typeof value !== 'string') {
+ throw new Error(`Expected a color string, received ${typeof value}`);
+ }
+
+ return color(value);
+};
+
+const ratio = (foreground: unknown, background: unknown) =>
+ asColor(foreground).contrast(asColor(background));
+
+describe('contrast levels', () => {
+ describe.each(MODES)('%s', (mode) => {
+ it.each(NON_STANDARD)('defines every color role at %s', (contrast) => {
+ const standard = buildScheme(palette, { mode });
+ const scheme = buildScheme(palette, { mode, contrast });
+
+ // Catches a role that is missing from the generated table.
+ expect(Object.keys(scheme).sort()).toEqual(Object.keys(standard).sort());
+
+ Object.entries(scheme).forEach(([role, value]) => {
+ expect(value).toBeDefined();
+ expect(role.length && value).toBeTruthy();
+ });
+
+ expect(Object.keys(scheme.elevation).sort()).toEqual(
+ Object.keys(standard.elevation).sort()
+ );
+ });
+
+ it.each(NON_STANDARD)('meets WCAG contrast targets at %s', (contrast) => {
+ const { colors } = createTheme({ dark: mode === 'dark', contrast });
+ const target = WCAG_TARGET[contrast];
+
+ const failures = CONTRAST_PAIRS.filter(
+ ([foreground, background]) =>
+ ratio(colors[foreground], colors[background]) < target
+ ).map(([foreground, background]) => {
+ const value = ratio(colors[foreground], colors[background]);
+ return `${foreground} on ${background}: ${value.toFixed(2)} < ${target}`;
+ });
+
+ expect(failures).toEqual([]);
+ });
+
+ it.each(NON_STANDARD)(
+ 'raises contrast above standard at %s',
+ (contrast) => {
+ const isDark = mode === 'dark';
+ const standard = createTheme({ dark: isDark }).colors;
+ const raised = createTheme({ dark: isDark, contrast }).colors;
+
+ expect(ratio(raised.onPrimary, raised.primary)).toBeGreaterThan(
+ ratio(standard.onPrimary, standard.primary)
+ );
+ }
+ );
+ });
+
+ it('derives the pressed state layer from the scheme onSurface', () => {
+ const { colors } = createTheme({ contrast: 'high' });
+
+ expect(colors.stateLayerPressed).toBe(
+ asColor(colors.onSurface).alpha(0.1).rgb().string()
+ );
+ expect(colors.stateLayerPressed).not.toBe(
+ LightTheme.colors.stateLayerPressed
+ );
+ });
+
+ it('keeps elevation level0 transparent', () => {
+ NON_STANDARD.forEach((contrast) => {
+ expect(createTheme({ contrast }).colors.elevation.level0).toBe(
+ 'transparent'
+ );
+ });
+ });
+
+ it('defaults to standard, leaving the built-in themes unchanged', () => {
+ expect(createTheme({ dark: false }).colors).toStrictEqual(
+ LightTheme.colors
+ );
+ expect(createTheme({ dark: true }).colors).toStrictEqual(DarkTheme.colors);
+ expect(LightTheme.contrast).toBe('standard');
+ expect(DarkTheme.contrast).toBe('standard');
+ });
+});
diff --git a/src/theme/provider.tsx b/src/theme/provider.tsx
index 546a9dcf88..5ed9b9ee93 100644
--- a/src/theme/provider.tsx
+++ b/src/theme/provider.tsx
@@ -5,7 +5,8 @@ import { createTheming } from '@callstack/react-theme-provider';
import type { $DeepPartial } from '@callstack/react-theme-provider';
import { DarkTheme, LightTheme } from './schemes';
-import type { Theme, NavigationTheme } from './types';
+import { createTheme } from './schemes/createTheme';
+import type { ContrastLevel, Theme, NavigationTheme } from './types';
const {
ThemeProvider,
@@ -75,15 +76,26 @@ export const defaultThemes = {
dark: DarkTheme,
};
-export const getTheme = (
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- isDark: Scheme = false as Scheme
-): (typeof defaultThemes)[Scheme extends true ? 'dark' : 'light'] => {
- const scheme = isDark ? 'dark' : 'light';
-
- return defaultThemes[scheme];
+/** Every light or dark and contrast pair, built once so that switching
+ * contrast at runtime does not rebuild a scheme. */
+const contrastThemes: Record<'light' | 'dark', Record> = {
+ light: {
+ standard: LightTheme,
+ medium: createTheme({ dark: false, contrast: 'medium' }),
+ high: createTheme({ dark: false, contrast: 'high' }),
+ },
+ dark: {
+ standard: DarkTheme,
+ medium: createTheme({ dark: true, contrast: 'medium' }),
+ high: createTheme({ dark: true, contrast: 'high' }),
+ },
};
+export const getTheme = (
+ isDark: boolean = false,
+ contrast: ContrastLevel = 'standard'
+): Theme => contrastThemes[isDark ? 'dark' : 'light'][contrast];
+
export function adaptNavigationTheme(themes: {
reactNavigationLight: T;
materialLight?: Theme;
diff --git a/src/theme/schemes/DarkTheme.tsx b/src/theme/schemes/DarkTheme.tsx
index 9b7ff60ef4..3c194559f8 100644
--- a/src/theme/schemes/DarkTheme.tsx
+++ b/src/theme/schemes/DarkTheme.tsx
@@ -1,12 +1,4 @@
-import { themeDefaults } from './base';
-import { tokens } from '../tokens';
-import { buildScheme } from '../tokens/sys/color';
-import { defaultShapes } from '../tokens/sys/shape';
+import { createTheme } from './createTheme';
import type { Theme } from '../types';
-export const DarkTheme: Theme = {
- ...themeDefaults,
- dark: true,
- colors: buildScheme(tokens.md.ref.palette, { mode: 'dark' }),
- shapes: defaultShapes,
-};
+export const DarkTheme: Theme = createTheme({ dark: true });
diff --git a/src/theme/schemes/DynamicTheme.android.tsx b/src/theme/schemes/DynamicTheme.android.tsx
index 0fb9c29b0e..2b86b5162c 100644
--- a/src/theme/schemes/DynamicTheme.android.tsx
+++ b/src/theme/schemes/DynamicTheme.android.tsx
@@ -1,9 +1,10 @@
import { Platform, PlatformColor, type ColorValue } from 'react-native';
+import { createTheme } from './createTheme';
import { DarkTheme } from './DarkTheme';
import { LightTheme } from './LightTheme';
import { Palette } from '../tokens';
-import type { Theme, ThemeColors } from '../types';
+import type { ContrastLevel, Theme, ThemeColors } from '../types';
const apiLevel = Platform.OS === 'android' ? Platform.Version : null;
@@ -489,3 +490,22 @@ export const DynamicDarkTheme: Theme = {
...DarkTheme,
colors: { ...DarkTheme.colors, ...darkDynamicColors },
};
+
+/** Android has no high contrast version of its system colors, so dynamic
+ * color is only used at `standard` contrast. */
+export const isDynamicColorSupportedAtContrast = (contrast: ContrastLevel) =>
+ isDynamicColorSupported && contrast === 'standard';
+
+/**
+ * Dynamic theme for a scheme and contrast level.
+ */
+export const getDynamicTheme = (
+ isDark: boolean,
+ contrast: ContrastLevel = 'standard'
+): Theme => {
+ if (!isDynamicColorSupportedAtContrast(contrast)) {
+ return createTheme({ dark: isDark, contrast });
+ }
+
+ return isDark ? DynamicDarkTheme : DynamicLightTheme;
+};
diff --git a/src/theme/schemes/DynamicTheme.tsx b/src/theme/schemes/DynamicTheme.tsx
index a9049b86bf..ab0e4aa0c4 100644
--- a/src/theme/schemes/DynamicTheme.tsx
+++ b/src/theme/schemes/DynamicTheme.tsx
@@ -1,4 +1,15 @@
+import { createTheme } from './createTheme';
+import type { ContrastLevel, Theme } from '../types';
+
export { DarkTheme as DynamicDarkTheme } from './DarkTheme';
export { LightTheme as DynamicLightTheme } from './LightTheme';
export const isDynamicColorSupported = false;
+
+export const isDynamicColorSupportedAtContrast = (_contrast: ContrastLevel) =>
+ false;
+
+export const getDynamicTheme = (
+ isDark: boolean,
+ contrast: ContrastLevel = 'standard'
+): Theme => createTheme({ dark: isDark, contrast });
diff --git a/src/theme/schemes/LightTheme.tsx b/src/theme/schemes/LightTheme.tsx
index 42593d5d42..7fde956331 100644
--- a/src/theme/schemes/LightTheme.tsx
+++ b/src/theme/schemes/LightTheme.tsx
@@ -1,12 +1,4 @@
-import { themeDefaults } from './base';
-import { tokens } from '../tokens';
-import { buildScheme } from '../tokens/sys/color';
-import { defaultShapes } from '../tokens/sys/shape';
+import { createTheme } from './createTheme';
import type { Theme } from '../types';
-export const LightTheme: Theme = {
- ...themeDefaults,
- dark: false,
- colors: buildScheme(tokens.md.ref.palette, { mode: 'light' }),
- shapes: defaultShapes,
-};
+export const LightTheme: Theme = createTheme({ dark: false });
diff --git a/src/theme/schemes/base.ts b/src/theme/schemes/base.ts
index 180ec1c155..c9a89aeb0d 100644
--- a/src/theme/schemes/base.ts
+++ b/src/theme/schemes/base.ts
@@ -4,7 +4,7 @@ import { defaultShapes } from '../tokens/sys/shape';
import { defaultFonts } from '../tokens/sys/typography';
import type { Theme } from '../types';
-type ThemeDefaults = Omit;
+type ThemeDefaults = Omit;
export const themeDefaults: ThemeDefaults = {
animation: {
diff --git a/src/theme/schemes/createTheme.ts b/src/theme/schemes/createTheme.ts
new file mode 100644
index 0000000000..6aed8d2572
--- /dev/null
+++ b/src/theme/schemes/createTheme.ts
@@ -0,0 +1,28 @@
+import { themeDefaults } from './base';
+import { tokens } from '../tokens';
+import { buildScheme } from '../tokens/sys/color';
+import type { ContrastLevel, Theme } from '../types';
+
+export type CreateThemeOptions = {
+ dark?: boolean;
+ contrast?: ContrastLevel;
+};
+
+/**
+ * Builds a theme for a given color scheme and contrast level.
+ *
+ * Prefer the `contrast` prop on `PaperProvider` over calling this directly,
+ * because passing a `theme` object turns off automatic system dark mode.
+ */
+export const createTheme = ({
+ dark = false,
+ contrast = 'standard',
+}: CreateThemeOptions = {}): Theme => ({
+ ...themeDefaults,
+ dark,
+ contrast,
+ colors: buildScheme(tokens.md.ref.palette, {
+ mode: dark ? 'dark' : 'light',
+ contrast,
+ }),
+});
diff --git a/src/theme/schemes/index.ts b/src/theme/schemes/index.ts
index 37407657e2..ce456bedcc 100644
--- a/src/theme/schemes/index.ts
+++ b/src/theme/schemes/index.ts
@@ -1,7 +1,10 @@
export { LightTheme } from './LightTheme';
export { DarkTheme } from './DarkTheme';
+export { createTheme, type CreateThemeOptions } from './createTheme';
export {
DynamicLightTheme,
DynamicDarkTheme,
+ getDynamicTheme,
isDynamicColorSupported,
+ isDynamicColorSupportedAtContrast,
} from './DynamicTheme';
diff --git a/src/theme/tokens/sys/color.ts b/src/theme/tokens/sys/color.ts
index a6c22ca0fb..ba54995013 100644
--- a/src/theme/tokens/sys/color.ts
+++ b/src/theme/tokens/sys/color.ts
@@ -1,7 +1,8 @@
import color from 'color';
+import { contrastSchemes } from './contrastSchemes';
import { state } from './state';
-import type { ElevationColors, ThemeColors } from '../../types';
+import type { ContrastLevel, ElevationColors, ThemeColors } from '../../types';
import { palette as defaultPalette } from '../ref/palette';
type Palette = typeof defaultPalette;
@@ -10,11 +11,11 @@ type PaletteKey = keyof Palette;
/** Roles that map 1:1 to a palette key. Excludes the computed fields. */
type MappedRoles = Omit;
-type Contrast = 'standard'; // extend with 'medium' | 'high' when those ship
-
+/** Only `standard` uses the reference palette steps. The other levels need
+ * tones that are not in the palette, so they live in `./contrastSchemes`. */
const roleToTone: Record<
'light' | 'dark',
- Record>
+ Record<'standard', Record>
> = {
light: {
standard: {
@@ -124,7 +125,10 @@ const roleToTone: Record<
const elevationToTone: Record<
'light' | 'dark',
- Record, PaletteKey>>
+ Record<
+ 'standard',
+ Record, PaletteKey>
+ >
> = {
light: {
standard: {
@@ -146,11 +150,34 @@ const elevationToTone: Record<
},
};
+/** Works out the press state layer up front, because changing alpha at
+ * runtime breaks PlatformColor on Android.
+ * @see ThemeColors.stateLayerPressed */
+const withPressedOpacity = (onSurface: string) =>
+ color(onSurface).alpha(state.opacity.pressed).rgb().string();
+
+/**
+ * Builds the color scheme for a mode and contrast level.
+ *
+ * `palette` is only used at `standard` contrast. The `medium` and `high`
+ * schemes already hold color values, so a custom `palette` is ignored there.
+ */
export function buildScheme(
palette: Palette,
- opts: { mode: 'light' | 'dark'; contrast?: Contrast }
+ opts: { mode: 'light' | 'dark'; contrast?: ContrastLevel }
): ThemeColors {
const contrast = opts.contrast ?? 'standard';
+
+ if (contrast !== 'standard') {
+ const { roles, elevation } = contrastSchemes[opts.mode][contrast];
+
+ return {
+ ...roles,
+ stateLayerPressed: withPressedOpacity(roles.onSurface),
+ elevation: { level0: 'transparent', ...elevation },
+ };
+ }
+
const tones = roleToTone[opts.mode][contrast];
const elevTones = elevationToTone[opts.mode][contrast];
@@ -161,10 +188,7 @@ export function buildScheme(
return {
...mapped,
- stateLayerPressed: color(palette[tones.onSurface])
- .alpha(state.opacity.pressed)
- .rgb()
- .string(),
+ stateLayerPressed: withPressedOpacity(palette[tones.onSurface]),
elevation: {
level0: 'transparent',
level1: palette[elevTones.level1],
diff --git a/src/theme/tokens/sys/contrastSchemes.ts b/src/theme/tokens/sys/contrastSchemes.ts
new file mode 100644
index 0000000000..30c48f1615
--- /dev/null
+++ b/src/theme/tokens/sys/contrastSchemes.ts
@@ -0,0 +1,264 @@
+/**
+ * GENERATED by scripts/generate-contrast-tokens.ts, do not edit by hand.
+ * Run `yarn generate-contrast-tokens` to regenerate.
+ *
+ * MD3 medium and high contrast schemes, seeded from #6750A4.
+ *
+ */
+import type { ContrastLevel, ElevationColors, ThemeColors } from '../../types';
+
+type GeneratedContrast = Exclude;
+
+type GeneratedScheme = {
+ roles: Record<
+ Exclude,
+ string
+ >;
+ elevation: Record, string>;
+};
+
+export const contrastSchemes: Record<
+ 'light' | 'dark',
+ Record
+> = {
+ light: {
+ medium: {
+ roles: {
+ primary: 'rgba(60, 45, 99, 1)',
+ primaryContainer: 'rgba(116, 100, 159, 1)',
+ secondary: 'rgba(57, 51, 71, 1)',
+ secondaryContainer: 'rgba(113, 106, 128, 1)',
+ tertiary: 'rgba(80, 43, 56, 1)',
+ tertiaryContainer: 'rgba(142, 96, 111, 1)',
+ surface: 'rgba(253, 247, 255, 1)',
+ surfaceDim: 'rgba(202, 197, 204, 1)',
+ surfaceBright: 'rgba(253, 247, 255, 1)',
+ surfaceContainerLowest: 'rgba(255, 255, 255, 1)',
+ surfaceContainerLow: 'rgba(248, 242, 250, 1)',
+ surfaceContainer: 'rgba(236, 230, 238, 1)',
+ surfaceContainerHigh: 'rgba(225, 219, 227, 1)',
+ surfaceContainerHighest: 'rgba(213, 208, 216, 1)',
+ surfaceVariant: 'rgba(231, 224, 235, 1)',
+ background: 'rgba(253, 247, 255, 1)',
+ error: 'rgba(116, 0, 6, 1)',
+ errorContainer: 'rgba(207, 44, 39, 1)',
+ onPrimary: 'rgba(255, 255, 255, 1)',
+ onPrimaryContainer: 'rgba(255, 255, 255, 1)',
+ onSecondary: 'rgba(255, 255, 255, 1)',
+ onSecondaryContainer: 'rgba(255, 255, 255, 1)',
+ onTertiary: 'rgba(255, 255, 255, 1)',
+ onTertiaryContainer: 'rgba(255, 255, 255, 1)',
+ onSurface: 'rgba(18, 16, 22, 1)',
+ onSurfaceVariant: 'rgba(56, 53, 61, 1)',
+ onError: 'rgba(255, 255, 255, 1)',
+ onErrorContainer: 'rgba(255, 255, 255, 1)',
+ onBackground: 'rgba(29, 27, 32, 1)',
+ outline: 'rgba(84, 81, 90, 1)',
+ outlineVariant: 'rgba(111, 107, 117, 1)',
+ inverseSurface: 'rgba(50, 47, 53, 1)',
+ inverseOnSurface: 'rgba(245, 239, 247, 1)',
+ inversePrimary: 'rgba(207, 189, 254, 1)',
+ primaryFixed: 'rgba(116, 100, 159, 1)',
+ primaryFixedDim: 'rgba(91, 76, 132, 1)',
+ onPrimaryFixed: 'rgba(255, 255, 255, 1)',
+ onPrimaryFixedVariant: 'rgba(255, 255, 255, 1)',
+ secondaryFixed: 'rgba(113, 106, 128, 1)',
+ secondaryFixedDim: 'rgba(88, 82, 103, 1)',
+ onSecondaryFixed: 'rgba(255, 255, 255, 1)',
+ onSecondaryFixedVariant: 'rgba(255, 255, 255, 1)',
+ tertiaryFixed: 'rgba(142, 96, 111, 1)',
+ tertiaryFixedDim: 'rgba(115, 72, 86, 1)',
+ onTertiaryFixed: 'rgba(255, 255, 255, 1)',
+ onTertiaryFixedVariant: 'rgba(255, 255, 255, 1)',
+ shadow: 'rgba(0, 0, 0, 1)',
+ scrim: 'rgba(0, 0, 0, 1)',
+ },
+ elevation: {
+ level1: 'rgba(248, 242, 250, 1)',
+ level2: 'rgba(236, 230, 238, 1)',
+ level3: 'rgba(225, 219, 227, 1)',
+ level4: 'rgba(225, 219, 227, 1)',
+ level5: 'rgba(213, 208, 216, 1)',
+ },
+ },
+ high: {
+ roles: {
+ primary: 'rgba(49, 34, 89, 1)',
+ primaryContainer: 'rgba(79, 64, 120, 1)',
+ secondary: 'rgba(47, 41, 60, 1)',
+ secondaryContainer: 'rgba(76, 70, 91, 1)',
+ tertiary: 'rgba(69, 33, 46, 1)',
+ tertiaryContainer: 'rgba(102, 61, 75, 1)',
+ surface: 'rgba(253, 247, 255, 1)',
+ surfaceDim: 'rgba(188, 183, 191, 1)',
+ surfaceBright: 'rgba(253, 247, 255, 1)',
+ surfaceContainerLowest: 'rgba(255, 255, 255, 1)',
+ surfaceContainerLow: 'rgba(245, 239, 247, 1)',
+ surfaceContainer: 'rgba(230, 224, 233, 1)',
+ surfaceContainerHigh: 'rgba(216, 210, 218, 1)',
+ surfaceContainerHighest: 'rgba(202, 197, 204, 1)',
+ surfaceVariant: 'rgba(231, 224, 235, 1)',
+ background: 'rgba(253, 247, 255, 1)',
+ error: 'rgba(96, 0, 4, 1)',
+ errorContainer: 'rgba(152, 0, 10, 1)',
+ onPrimary: 'rgba(255, 255, 255, 1)',
+ onPrimaryContainer: 'rgba(255, 255, 255, 1)',
+ onSecondary: 'rgba(255, 255, 255, 1)',
+ onSecondaryContainer: 'rgba(255, 255, 255, 1)',
+ onTertiary: 'rgba(255, 255, 255, 1)',
+ onTertiaryContainer: 'rgba(255, 255, 255, 1)',
+ onSurface: 'rgba(0, 0, 0, 1)',
+ onSurfaceVariant: 'rgba(0, 0, 0, 1)',
+ onError: 'rgba(255, 255, 255, 1)',
+ onErrorContainer: 'rgba(255, 255, 255, 1)',
+ onBackground: 'rgba(29, 27, 32, 1)',
+ outline: 'rgba(46, 43, 51, 1)',
+ outlineVariant: 'rgba(75, 72, 81, 1)',
+ inverseSurface: 'rgba(50, 47, 53, 1)',
+ inverseOnSurface: 'rgba(255, 255, 255, 1)',
+ inversePrimary: 'rgba(207, 189, 254, 1)',
+ primaryFixed: 'rgba(79, 64, 120, 1)',
+ primaryFixedDim: 'rgba(56, 41, 96, 1)',
+ onPrimaryFixed: 'rgba(255, 255, 255, 1)',
+ onPrimaryFixedVariant: 'rgba(255, 255, 255, 1)',
+ secondaryFixed: 'rgba(76, 70, 91, 1)',
+ secondaryFixedDim: 'rgba(53, 48, 67, 1)',
+ onSecondaryFixed: 'rgba(255, 255, 255, 1)',
+ onSecondaryFixedVariant: 'rgba(255, 255, 255, 1)',
+ tertiaryFixed: 'rgba(102, 61, 75, 1)',
+ tertiaryFixedDim: 'rgba(76, 39, 52, 1)',
+ onTertiaryFixed: 'rgba(255, 255, 255, 1)',
+ onTertiaryFixedVariant: 'rgba(255, 255, 255, 1)',
+ shadow: 'rgba(0, 0, 0, 1)',
+ scrim: 'rgba(0, 0, 0, 1)',
+ },
+ elevation: {
+ level1: 'rgba(245, 239, 247, 1)',
+ level2: 'rgba(230, 224, 233, 1)',
+ level3: 'rgba(216, 210, 218, 1)',
+ level4: 'rgba(216, 210, 218, 1)',
+ level5: 'rgba(202, 197, 204, 1)',
+ },
+ },
+ },
+ dark: {
+ medium: {
+ roles: {
+ primary: 'rgba(227, 214, 255, 1)',
+ primaryContainer: 'rgba(152, 135, 197, 1)',
+ secondary: 'rgba(226, 216, 242, 1)',
+ secondaryContainer: 'rgba(149, 141, 164, 1)',
+ tertiary: 'rgba(255, 208, 221, 1)',
+ tertiaryContainer: 'rgba(181, 131, 146, 1)',
+ surface: 'rgba(20, 18, 24, 1)',
+ surfaceDim: 'rgba(20, 18, 24, 1)',
+ surfaceBright: 'rgba(70, 67, 74, 1)',
+ surfaceContainerLowest: 'rgba(8, 7, 11, 1)',
+ surfaceContainerLow: 'rgba(31, 29, 34, 1)',
+ surfaceContainer: 'rgba(41, 39, 45, 1)',
+ surfaceContainerHigh: 'rgba(52, 49, 56, 1)',
+ surfaceContainerHighest: 'rgba(63, 60, 67, 1)',
+ surfaceVariant: 'rgba(73, 69, 78, 1)',
+ background: 'rgba(20, 18, 24, 1)',
+ error: 'rgba(255, 210, 204, 1)',
+ errorContainer: 'rgba(255, 84, 73, 1)',
+ onPrimary: 'rgba(43, 27, 82, 1)',
+ onPrimaryContainer: 'rgba(0, 0, 0, 1)',
+ onSecondary: 'rgba(40, 35, 54, 1)',
+ onSecondaryContainer: 'rgba(0, 0, 0, 1)',
+ onTertiary: 'rgba(61, 26, 39, 1)',
+ onTertiaryContainer: 'rgba(0, 0, 0, 1)',
+ onSurface: 'rgba(255, 255, 255, 1)',
+ onSurfaceVariant: 'rgba(224, 218, 229, 1)',
+ onError: 'rgba(84, 0, 3, 1)',
+ onErrorContainer: 'rgba(0, 0, 0, 1)',
+ onBackground: 'rgba(230, 224, 233, 1)',
+ outline: 'rgba(181, 176, 187, 1)',
+ outlineVariant: 'rgba(147, 142, 153, 1)',
+ inverseSurface: 'rgba(230, 224, 233, 1)',
+ inverseOnSurface: 'rgba(43, 41, 47, 1)',
+ inversePrimary: 'rgba(78, 63, 119, 1)',
+ primaryFixed: 'rgba(233, 221, 255, 1)',
+ primaryFixedDim: 'rgba(207, 189, 254, 1)',
+ onPrimaryFixed: 'rgba(22, 3, 61, 1)',
+ onPrimaryFixedVariant: 'rgba(60, 45, 99, 1)',
+ secondaryFixed: 'rgba(232, 222, 248, 1)',
+ secondaryFixedDim: 'rgba(203, 194, 219, 1)',
+ onSecondaryFixed: 'rgba(19, 14, 32, 1)',
+ onSecondaryFixedVariant: 'rgba(57, 51, 71, 1)',
+ tertiaryFixed: 'rgba(255, 217, 227, 1)',
+ tertiaryFixedDim: 'rgba(239, 184, 200, 1)',
+ onTertiaryFixed: 'rgba(36, 6, 19, 1)',
+ onTertiaryFixedVariant: 'rgba(80, 43, 56, 1)',
+ shadow: 'rgba(0, 0, 0, 1)',
+ scrim: 'rgba(0, 0, 0, 1)',
+ },
+ elevation: {
+ level1: 'rgba(31, 29, 34, 1)',
+ level2: 'rgba(41, 39, 45, 1)',
+ level3: 'rgba(52, 49, 56, 1)',
+ level4: 'rgba(52, 49, 56, 1)',
+ level5: 'rgba(63, 60, 67, 1)',
+ },
+ },
+ high: {
+ roles: {
+ primary: 'rgba(245, 237, 255, 1)',
+ primaryContainer: 'rgba(203, 185, 250, 1)',
+ secondary: 'rgba(245, 237, 255, 1)',
+ secondaryContainer: 'rgba(200, 191, 216, 1)',
+ tertiary: 'rgba(255, 235, 239, 1)',
+ tertiaryContainer: 'rgba(235, 180, 196, 1)',
+ surface: 'rgba(20, 18, 24, 1)',
+ surfaceDim: 'rgba(20, 18, 24, 1)',
+ surfaceBright: 'rgba(82, 79, 85, 1)',
+ surfaceContainerLowest: 'rgba(0, 0, 0, 1)',
+ surfaceContainerLow: 'rgba(33, 31, 36, 1)',
+ surfaceContainer: 'rgba(50, 47, 53, 1)',
+ surfaceContainerHigh: 'rgba(61, 58, 65, 1)',
+ surfaceContainerHighest: 'rgba(72, 70, 76, 1)',
+ surfaceVariant: 'rgba(73, 69, 78, 1)',
+ background: 'rgba(20, 18, 24, 1)',
+ error: 'rgba(255, 236, 233, 1)',
+ errorContainer: 'rgba(255, 174, 164, 1)',
+ onPrimary: 'rgba(0, 0, 0, 1)',
+ onPrimaryContainer: 'rgba(15, 0, 51, 1)',
+ onSecondary: 'rgba(0, 0, 0, 1)',
+ onSecondaryContainer: 'rgba(13, 8, 26, 1)',
+ onTertiary: 'rgba(0, 0, 0, 1)',
+ onTertiaryContainer: 'rgba(29, 2, 13, 1)',
+ onSurface: 'rgba(255, 255, 255, 1)',
+ onSurfaceVariant: 'rgba(255, 255, 255, 1)',
+ onError: 'rgba(0, 0, 0, 1)',
+ onErrorContainer: 'rgba(34, 0, 1, 1)',
+ onBackground: 'rgba(230, 224, 233, 1)',
+ outline: 'rgba(244, 238, 249, 1)',
+ outlineVariant: 'rgba(198, 192, 203, 1)',
+ inverseSurface: 'rgba(230, 224, 233, 1)',
+ inverseOnSurface: 'rgba(0, 0, 0, 1)',
+ inversePrimary: 'rgba(78, 63, 119, 1)',
+ primaryFixed: 'rgba(233, 221, 255, 1)',
+ primaryFixedDim: 'rgba(207, 189, 254, 1)',
+ onPrimaryFixed: 'rgba(0, 0, 0, 1)',
+ onPrimaryFixedVariant: 'rgba(22, 3, 61, 1)',
+ secondaryFixed: 'rgba(232, 222, 248, 1)',
+ secondaryFixedDim: 'rgba(203, 194, 219, 1)',
+ onSecondaryFixed: 'rgba(0, 0, 0, 1)',
+ onSecondaryFixedVariant: 'rgba(19, 14, 32, 1)',
+ tertiaryFixed: 'rgba(255, 217, 227, 1)',
+ tertiaryFixedDim: 'rgba(239, 184, 200, 1)',
+ onTertiaryFixed: 'rgba(0, 0, 0, 1)',
+ onTertiaryFixedVariant: 'rgba(36, 6, 19, 1)',
+ shadow: 'rgba(0, 0, 0, 1)',
+ scrim: 'rgba(0, 0, 0, 1)',
+ },
+ elevation: {
+ level1: 'rgba(33, 31, 36, 1)',
+ level2: 'rgba(50, 47, 53, 1)',
+ level3: 'rgba(61, 58, 65, 1)',
+ level4: 'rgba(61, 58, 65, 1)',
+ level5: 'rgba(72, 70, 76, 1)',
+ },
+ },
+ },
+} as const;
diff --git a/src/theme/types/theme.ts b/src/theme/types/theme.ts
index a4ce2288ae..9dd54d6cf7 100644
--- a/src/theme/types/theme.ts
+++ b/src/theme/types/theme.ts
@@ -6,8 +6,11 @@ import type { MotionConfig } from './motion';
import type { ThemeShapes } from './shape';
import type { Typescale } from './typography';
+export type ContrastLevel = 'standard' | 'medium' | 'high';
+
export type Theme = {
dark: boolean;
+ contrast: ContrastLevel;
animation: {
scale: number;
};
diff --git a/yarn.lock b/yarn.lock
index dcc0fa4cac..2254fd30f3 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3988,6 +3988,13 @@ __metadata:
languageName: node
linkType: hard
+"@material/material-color-utilities@npm:0.3.0":
+ version: 0.3.0
+ resolution: "@material/material-color-utilities@npm:0.3.0"
+ checksum: 10c0/3bef025428b893f2acc9e9e2bd186363a60b7c0836fe43c78222e29fe67dc579618e844f0661a20657ed9f7fd8b94fd43a2961892894d0b6a2ba5264ce2673f8
+ languageName: node
+ linkType: hard
+
"@mdx-js/mdx@npm:^0.20.3":
version: 0.20.3
resolution: "@mdx-js/mdx@npm:0.20.3"
@@ -18202,6 +18209,7 @@ __metadata:
"@commitlint/config-conventional": "npm:^8.3.4"
"@eslint/js": "npm:9.39.4"
"@jest/globals": "npm:^29.7.0"
+ "@material/material-color-utilities": "npm:0.3.0"
"@react-native-vector-icons/material-design-icons": "npm:^12.0.0"
"@react-native/babel-preset": "npm:^0.85.3"
"@react-native/jest-preset": "npm:^0.85.3"