diff --git a/docs/6.x/docs/guides/theming.mdx b/docs/6.x/docs/guides/theming.mdx
index 58836dc167..6661f01e93 100644
--- a/docs/6.x/docs/guides/theming.mdx
+++ b/docs/6.x/docs/guides/theming.mdx
@@ -244,6 +244,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 text and background colors, which helps users with low vision and makes the app easier to read in bright light.
+
+Each level is an exported theme that you pass to `PaperProvider`, the same way as the default themes:
+
+```js
+import * as React from 'react';
+import { PaperProvider, HighContrastLightTheme } from 'react-native-paper';
+
+export default function Main() {
+ return (
+
+
+
+ );
+}
+```
+
+The available themes are:
+
+- `LightTheme` and `DarkTheme`
+- `MediumContrastLightTheme` and `MediumContrastDarkTheme`
+- `HighContrastLightTheme` and `HighContrastDarkTheme`
+
+The `medium` and `high` schemes meet the WCAG contrast ratios of 4.5:1 and 7:1 respectively for every text and background role pair.
+
+Note that passing a `theme` turns off automatic system dark mode, so pick the theme yourself when you follow the system setting:
+
+```js
+import { useColorScheme } from 'react-native';
+import {
+ PaperProvider,
+ HighContrastDarkTheme,
+ HighContrastLightTheme,
+} from 'react-native-paper';
+
+export default function Main() {
+ const isDarkMode = useColorScheme() === 'dark';
+
+ return (
+
+
+
+ );
+}
+```
+
+### Contrast and dynamic colors
+
+There are matching dynamic themes: `MediumContrastDynamicLightTheme`, `HighContrastDynamicLightTheme`, `MediumContrastDynamicDarkTheme` and `HighContrastDynamicDarkTheme`.
+
+Android exposes no contrast adjusted version of its system palette, so these fall back to the static schemes. Using the standard contrast system colors at a raised level would quietly lower the contrast you asked for.
+
## 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 49bdd5f99d..eb5b892d66 100644
--- a/example/src/DrawerItems.tsx
+++ b/example/src/DrawerItems.tsx
@@ -11,6 +11,7 @@ import {
Drawer,
Palette,
Portal,
+ SegmentedButtons,
Switch,
Text,
TouchableRipple,
@@ -104,9 +105,11 @@ function DrawerItems() {
toggleCollapsed,
toggleCustomFont,
toggleRippleEffect,
+ setContrast,
customFontLoaded,
rippleEffectEnabled,
collapsed,
+ contrast,
rtl: isRTL,
theme: { dark: isDarkTheme },
shouldUseDynamicTheme,
@@ -191,6 +194,20 @@ function DrawerItems() {
+
+ Contrast
+ setContrast(value)}
+ density="small"
+ buttons={[
+ { value: 'standard', label: 'Standard' },
+ { value: 'medium', label: 'Medium' },
+ { value: 'high', label: 'High' },
+ ]}
+ />
+
+
RTL
@@ -277,6 +294,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..e6d2ca1acd 100644
--- a/example/src/PreferencesContext.tsx
+++ b/example/src/PreferencesContext.tsx
@@ -2,6 +2,8 @@ import * as React from 'react';
import type { Theme } from 'react-native-paper';
+type ContrastLevel = 'standard' | 'medium' | 'high';
+
export const PreferencesContext = React.createContext<{
toggleTheme: () => void;
toggleRtl: () => void;
@@ -9,7 +11,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..ec7b451f21 100644
--- a/example/src/index.tsx
+++ b/example/src/index.tsx
@@ -13,11 +13,19 @@ import * as SplashScreen from 'expo-splash-screen';
import { StatusBar } from 'expo-status-bar';
import * as Updates from 'expo-updates';
import {
- PaperProvider,
DarkTheme,
- LightTheme,
- DynamicLightTheme,
DynamicDarkTheme,
+ DynamicLightTheme,
+ HighContrastDarkTheme,
+ HighContrastDynamicDarkTheme,
+ HighContrastDynamicLightTheme,
+ HighContrastLightTheme,
+ LightTheme,
+ MediumContrastDarkTheme,
+ MediumContrastDynamicDarkTheme,
+ MediumContrastDynamicLightTheme,
+ MediumContrastLightTheme,
+ PaperProvider,
} from 'react-native-paper';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
@@ -26,12 +34,39 @@ import { PreferencesContext } from './PreferencesContext';
import App from './RootNavigator';
import { dynamicThemeSupported } from '../utils';
import {
- CombinedDarkTheme,
- CombinedDefaultTheme,
+ createCombinedTheme,
createConfiguredFontNavigationTheme,
createConfiguredFontTheme,
} from '../utils/themes';
+type ContrastLevel = 'standard' | 'medium' | 'high';
+
+const THEMES = {
+ light: {
+ standard: LightTheme,
+ medium: MediumContrastLightTheme,
+ high: HighContrastLightTheme,
+ },
+ dark: {
+ standard: DarkTheme,
+ medium: MediumContrastDarkTheme,
+ high: HighContrastDarkTheme,
+ },
+};
+
+const DYNAMIC_THEMES = {
+ light: {
+ standard: DynamicLightTheme,
+ medium: MediumContrastDynamicLightTheme,
+ high: HighContrastDynamicLightTheme,
+ },
+ dark: {
+ standard: DynamicDarkTheme,
+ medium: MediumContrastDynamicDarkTheme,
+ high: HighContrastDynamicDarkTheme,
+ },
+};
+
const PERSISTENCE_KEY = 'NAVIGATION_STATE';
const PREFERENCES_KEY = 'APP_PREFERENCES';
@@ -98,15 +133,11 @@ 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;
+ const themes =
+ dynamicThemeSupported && shouldUseDynamicTheme ? DYNAMIC_THEMES : THEMES;
+ const theme = themes[isDarkMode ? 'dark' : 'light'][contrast];
const direction = rtl ? 'rtl' : 'ltr';
@@ -122,6 +153,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 +183,7 @@ export default function PaperExample() {
JSON.stringify({
theme: isDarkMode ? 'dark' : 'light',
rtl,
+ contrast,
})
);
} catch (e) {
@@ -165,7 +204,7 @@ export default function PaperExample() {
};
void savePrefs();
- }, [direction, isDarkMode, isReady, rtl]);
+ }, [contrast, direction, isDarkMode, isReady, rtl]);
const preferences = React.useMemo(
() => ({
@@ -176,9 +215,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 +228,7 @@ export default function PaperExample() {
rtl,
theme,
collapsed,
+ contrast,
customFontLoaded,
shouldUseDynamicTheme,
rippleEffectEnabled,
@@ -197,7 +239,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/src/core/__tests__/theming.test.tsx b/src/core/__tests__/theming.test.tsx
index cb61aef20d..56282a50dd 100644
--- a/src/core/__tests__/theming.test.tsx
+++ b/src/core/__tests__/theming.test.tsx
@@ -1,6 +1,11 @@
import { describe, expect, it } from '@jest/globals';
-import { DarkTheme, LightTheme } from '../../theme/schemes';
+import {
+ DarkTheme,
+ HighContrastDarkTheme,
+ HighContrastLightTheme,
+ LightTheme,
+} from '../../theme/schemes';
import { adaptNavigationTheme } from '../theming';
const NavigationLightTheme = {
@@ -273,4 +278,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 = HighContrastLightTheme;
+ const materialDark = HighContrastDarkTheme;
+
+ 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/theme/__tests__/contrast.test.ts b/src/theme/__tests__/contrast.test.ts
new file mode 100644
index 0000000000..76df4d7d39
--- /dev/null
+++ b/src/theme/__tests__/contrast.test.ts
@@ -0,0 +1,213 @@
+import { describe, expect, it } from '@jest/globals';
+import color from 'color';
+
+import {
+ DarkTheme,
+ HighContrastDarkTheme,
+ HighContrastLightTheme,
+ LightTheme,
+ MediumContrastDarkTheme,
+ MediumContrastLightTheme,
+} from '../schemes';
+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[];
+
+const THEMES = {
+ light: {
+ standard: LightTheme,
+ medium: MediumContrastLightTheme,
+ high: HighContrastLightTheme,
+ },
+ dark: {
+ standard: DarkTheme,
+ medium: MediumContrastDarkTheme,
+ high: HighContrastDarkTheme,
+ },
+} as const;
+
+/**
+ * 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'],
+ ['onPrimaryFixed', 'primaryFixedDim'],
+ ['onSecondaryFixed', 'secondaryFixedDim'],
+ ['onTertiaryFixed', 'tertiaryFixedDim'],
+ ['onPrimaryFixedVariant', 'primaryFixedDim'],
+ ['onSecondaryFixedVariant', 'secondaryFixedDim'],
+ ['onTertiaryFixedVariant', 'tertiaryFixedDim'],
+ ['onPrimaryFixedVariant', 'primaryFixed'],
+ ['onSecondaryFixedVariant', 'secondaryFixed'],
+ ['onTertiaryFixedVariant', '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 } = THEMES[mode][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 standard = THEMES[mode].standard.colors;
+ const raised = THEMES[mode][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 } = HighContrastLightTheme;
+
+ expect(colors.stateLayerPressed).toBe(
+ asColor(colors.onSurface).alpha(0.1).rgb().string()
+ );
+ expect(colors.stateLayerPressed).not.toBe(
+ LightTheme.colors.stateLayerPressed
+ );
+ });
+
+ it('keeps the fixed surfaces the same at every contrast level', () => {
+ // The fixed surfaces stay put so they can be shared across light and dark.
+ // Their `on*FixedVariant` foregrounds still darken to hold the ratio.
+ let checked = 0;
+
+ MODES.forEach((mode) => {
+ const standard = THEMES[mode].standard.colors;
+
+ NON_STANDARD.forEach((contrast) => {
+ const raised = THEMES[mode][contrast].colors;
+
+ const surfacesOf = (colors: ThemeColors) =>
+ Object.entries(colors).filter(
+ ([role]) => role.includes('Fixed') && !role.startsWith('on')
+ );
+
+ const before = surfacesOf(standard);
+ checked += before.length;
+
+ expect(surfacesOf(raised)).toStrictEqual(before);
+ });
+ });
+
+ expect(checked).toBeGreaterThan(0);
+ });
+
+ it('keeps the fixed foregrounds readable as contrast rises', () => {
+ MODES.forEach((mode) => {
+ const standard = THEMES[mode].standard.colors;
+ const high = THEMES[mode].high.colors;
+
+ // The variant foreground darkens so it clears 7:1 on the dim surface.
+ expect(
+ ratio(high.onPrimaryFixedVariant, high.primaryFixedDim)
+ ).toBeGreaterThan(
+ ratio(standard.onPrimaryFixedVariant, standard.primaryFixedDim)
+ );
+ });
+ });
+
+ it('keeps a container distinct from its base role', () => {
+ // A container collapsing onto its base role means the scheme has clipped.
+ NON_STANDARD.forEach((contrast) => {
+ MODES.forEach((mode) => {
+ const { colors } = THEMES[mode][contrast];
+
+ expect(colors.primaryContainer).not.toBe(colors.primary);
+ expect(colors.secondaryContainer).not.toBe(colors.secondary);
+ expect(colors.tertiaryContainer).not.toBe(colors.tertiary);
+ expect(colors.errorContainer).not.toBe(colors.error);
+ expect(colors.outlineVariant).not.toBe(colors.outline);
+ });
+ });
+ });
+
+ it('keeps elevation level0 transparent', () => {
+ NON_STANDARD.forEach((contrast) => {
+ expect(THEMES.light[contrast].colors.elevation.level0).toBe(
+ 'transparent'
+ );
+ });
+ });
+
+ it('leaves the built-in themes at standard contrast', () => {
+ expect(LightTheme.colors).toStrictEqual(
+ buildScheme(palette, { mode: 'light' })
+ );
+ expect(DarkTheme.colors).toStrictEqual(
+ buildScheme(palette, { mode: 'dark' })
+ );
+ });
+});
diff --git a/src/theme/schemes/DarkTheme.tsx b/src/theme/schemes/DarkTheme.tsx
index 9b7ff60ef4..3ee90f6e94 100644
--- a/src/theme/schemes/DarkTheme.tsx
+++ b/src/theme/schemes/DarkTheme.tsx
@@ -1,12 +1,28 @@
import { themeDefaults } from './base';
import { tokens } from '../tokens';
import { buildScheme } from '../tokens/sys/color';
-import { defaultShapes } from '../tokens/sys/shape';
import type { Theme } from '../types';
export const DarkTheme: Theme = {
...themeDefaults,
dark: true,
colors: buildScheme(tokens.md.ref.palette, { mode: 'dark' }),
- shapes: defaultShapes,
+};
+
+export const MediumContrastDarkTheme: Theme = {
+ ...themeDefaults,
+ dark: true,
+ colors: buildScheme(tokens.md.ref.palette, {
+ mode: 'dark',
+ contrast: 'medium',
+ }),
+};
+
+export const HighContrastDarkTheme: Theme = {
+ ...themeDefaults,
+ dark: true,
+ colors: buildScheme(tokens.md.ref.palette, {
+ mode: 'dark',
+ contrast: 'high',
+ }),
};
diff --git a/src/theme/schemes/DynamicTheme.android.tsx b/src/theme/schemes/DynamicTheme.android.tsx
index 0fb9c29b0e..60a1850f11 100644
--- a/src/theme/schemes/DynamicTheme.android.tsx
+++ b/src/theme/schemes/DynamicTheme.android.tsx
@@ -489,3 +489,17 @@ export const DynamicDarkTheme: Theme = {
...DarkTheme,
colors: { ...DarkTheme.colors, ...darkDynamicColors },
};
+
+/**
+ * Android exposes no contrast adjusted version of its system palette, so the
+ * raised levels fall back to the static schemes. Using the standard contrast
+ * system colors there would quietly lower the contrast the user asked for.
+ */
+export {
+ MediumContrastLightTheme as MediumContrastDynamicLightTheme,
+ HighContrastLightTheme as HighContrastDynamicLightTheme,
+} from './LightTheme';
+export {
+ MediumContrastDarkTheme as MediumContrastDynamicDarkTheme,
+ HighContrastDarkTheme as HighContrastDynamicDarkTheme,
+} from './DarkTheme';
diff --git a/src/theme/schemes/DynamicTheme.tsx b/src/theme/schemes/DynamicTheme.tsx
index a9049b86bf..0f13ef0843 100644
--- a/src/theme/schemes/DynamicTheme.tsx
+++ b/src/theme/schemes/DynamicTheme.tsx
@@ -1,4 +1,8 @@
export { DarkTheme as DynamicDarkTheme } from './DarkTheme';
export { LightTheme as DynamicLightTheme } from './LightTheme';
+export { MediumContrastLightTheme as MediumContrastDynamicLightTheme } from './LightTheme';
+export { HighContrastLightTheme as HighContrastDynamicLightTheme } from './LightTheme';
+export { MediumContrastDarkTheme as MediumContrastDynamicDarkTheme } from './DarkTheme';
+export { HighContrastDarkTheme as HighContrastDynamicDarkTheme } from './DarkTheme';
export const isDynamicColorSupported = false;
diff --git a/src/theme/schemes/LightTheme.tsx b/src/theme/schemes/LightTheme.tsx
index 42593d5d42..2d2e6bc534 100644
--- a/src/theme/schemes/LightTheme.tsx
+++ b/src/theme/schemes/LightTheme.tsx
@@ -1,12 +1,28 @@
import { themeDefaults } from './base';
import { tokens } from '../tokens';
import { buildScheme } from '../tokens/sys/color';
-import { defaultShapes } from '../tokens/sys/shape';
import type { Theme } from '../types';
export const LightTheme: Theme = {
...themeDefaults,
dark: false,
colors: buildScheme(tokens.md.ref.palette, { mode: 'light' }),
- shapes: defaultShapes,
+};
+
+export const MediumContrastLightTheme: Theme = {
+ ...themeDefaults,
+ dark: false,
+ colors: buildScheme(tokens.md.ref.palette, {
+ mode: 'light',
+ contrast: 'medium',
+ }),
+};
+
+export const HighContrastLightTheme: Theme = {
+ ...themeDefaults,
+ dark: false,
+ colors: buildScheme(tokens.md.ref.palette, {
+ mode: 'light',
+ contrast: 'high',
+ }),
};
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/index.ts b/src/theme/schemes/index.ts
index 37407657e2..383f50004d 100644
--- a/src/theme/schemes/index.ts
+++ b/src/theme/schemes/index.ts
@@ -1,7 +1,19 @@
-export { LightTheme } from './LightTheme';
-export { DarkTheme } from './DarkTheme';
+export {
+ LightTheme,
+ MediumContrastLightTheme,
+ HighContrastLightTheme,
+} from './LightTheme';
+export {
+ DarkTheme,
+ MediumContrastDarkTheme,
+ HighContrastDarkTheme,
+} from './DarkTheme';
export {
DynamicLightTheme,
DynamicDarkTheme,
+ MediumContrastDynamicLightTheme,
+ HighContrastDynamicLightTheme,
+ MediumContrastDynamicDarkTheme,
+ HighContrastDynamicDarkTheme,
isDynamicColorSupported,
} from './DynamicTheme';
diff --git a/src/theme/tokens/sys/color.ts b/src/theme/tokens/sys/color.ts
index a6c22ca0fb..1a9a267f8a 100644
--- a/src/theme/tokens/sys/color.ts
+++ b/src/theme/tokens/sys/color.ts
@@ -1,7 +1,7 @@
import color from 'color';
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 +10,14 @@ 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
-
+/** Role to palette step for each MD3 contrast level.
+ *
+ * Raising contrast moves the accent and outline roles toward the extremes of
+ * their tonal palette. Surfaces and the `*Fixed` roles do not change, since
+ * MD3 keeps those stable across levels. */
const roleToTone: Record<
'light' | 'dark',
- Record>
+ Record>
> = {
light: {
standard: {
@@ -67,6 +70,106 @@ const roleToTone: Record<
shadow: 'neutral0',
scrim: 'neutral0',
},
+ medium: {
+ primary: 'primary30',
+ onPrimary: 'primary100',
+ primaryContainer: 'primary40',
+ onPrimaryContainer: 'primary100',
+ secondary: 'secondary30',
+ onSecondary: 'secondary100',
+ secondaryContainer: 'secondary40',
+ onSecondaryContainer: 'secondary100',
+ tertiary: 'tertiary30',
+ onTertiary: 'tertiary100',
+ tertiaryContainer: 'tertiary40',
+ onTertiaryContainer: 'tertiary100',
+ error: 'error30',
+ onError: 'error100',
+ errorContainer: 'error40',
+ onErrorContainer: 'error100',
+ surface: 'neutral98',
+ surfaceDim: 'neutral87',
+ surfaceBright: 'neutral98',
+ surfaceContainerLowest: 'neutral100',
+ surfaceContainerLow: 'neutral96',
+ surfaceContainer: 'neutral94',
+ surfaceContainerHigh: 'neutral92',
+ surfaceContainerHighest: 'neutral90',
+ surfaceVariant: 'neutralVariant90',
+ background: 'neutral98',
+ onSurface: 'neutral10',
+ onSurfaceVariant: 'neutralVariant30',
+ onBackground: 'neutral10',
+ outline: 'neutralVariant40',
+ outlineVariant: 'neutralVariant60',
+ inverseSurface: 'neutral20',
+ inverseOnSurface: 'neutral95',
+ inversePrimary: 'primary90',
+ primaryFixed: 'primary90',
+ primaryFixedDim: 'primary80',
+ onPrimaryFixed: 'primary10',
+ onPrimaryFixedVariant: 'primary30',
+ secondaryFixed: 'secondary90',
+ secondaryFixedDim: 'secondary80',
+ onSecondaryFixed: 'secondary10',
+ onSecondaryFixedVariant: 'secondary30',
+ tertiaryFixed: 'tertiary90',
+ tertiaryFixedDim: 'tertiary80',
+ onTertiaryFixed: 'tertiary10',
+ onTertiaryFixedVariant: 'tertiary30',
+ shadow: 'neutral0',
+ scrim: 'neutral0',
+ },
+ high: {
+ primary: 'primary20',
+ onPrimary: 'primary100',
+ primaryContainer: 'primary30',
+ onPrimaryContainer: 'primary100',
+ secondary: 'secondary20',
+ onSecondary: 'secondary100',
+ secondaryContainer: 'secondary30',
+ onSecondaryContainer: 'secondary100',
+ tertiary: 'tertiary20',
+ onTertiary: 'tertiary100',
+ tertiaryContainer: 'tertiary30',
+ onTertiaryContainer: 'tertiary100',
+ error: 'error20',
+ onError: 'error100',
+ errorContainer: 'error30',
+ onErrorContainer: 'error100',
+ surface: 'neutral98',
+ surfaceDim: 'neutral87',
+ surfaceBright: 'neutral98',
+ surfaceContainerLowest: 'neutral100',
+ surfaceContainerLow: 'neutral96',
+ surfaceContainer: 'neutral94',
+ surfaceContainerHigh: 'neutral92',
+ surfaceContainerHighest: 'neutral90',
+ surfaceVariant: 'neutralVariant90',
+ background: 'neutral98',
+ onSurface: 'neutral0',
+ onSurfaceVariant: 'neutralVariant20',
+ onBackground: 'neutral0',
+ outline: 'neutralVariant20',
+ outlineVariant: 'neutralVariant40',
+ inverseSurface: 'neutral20',
+ inverseOnSurface: 'neutral95',
+ inversePrimary: 'primary95',
+ primaryFixed: 'primary90',
+ primaryFixedDim: 'primary80',
+ onPrimaryFixed: 'primary10',
+ onPrimaryFixedVariant: 'primary20',
+ secondaryFixed: 'secondary90',
+ secondaryFixedDim: 'secondary80',
+ onSecondaryFixed: 'secondary10',
+ onSecondaryFixedVariant: 'secondary20',
+ tertiaryFixed: 'tertiary90',
+ tertiaryFixedDim: 'tertiary80',
+ onTertiaryFixed: 'tertiary10',
+ onTertiaryFixedVariant: 'tertiary20',
+ shadow: 'neutral0',
+ scrim: 'neutral0',
+ },
},
dark: {
standard: {
@@ -119,40 +222,143 @@ const roleToTone: Record<
shadow: 'neutral0',
scrim: 'neutral0',
},
+ medium: {
+ primary: 'primary90',
+ onPrimary: 'primary10',
+ primaryContainer: 'primary70',
+ onPrimaryContainer: 'primary0',
+ secondary: 'secondary90',
+ onSecondary: 'secondary10',
+ secondaryContainer: 'secondary70',
+ onSecondaryContainer: 'secondary0',
+ tertiary: 'tertiary90',
+ onTertiary: 'tertiary10',
+ tertiaryContainer: 'tertiary70',
+ onTertiaryContainer: 'tertiary0',
+ error: 'error90',
+ onError: 'error10',
+ errorContainer: 'error70',
+ onErrorContainer: 'error0',
+ surface: 'neutral6',
+ surfaceDim: 'neutral6',
+ surfaceBright: 'neutral24',
+ surfaceContainerLowest: 'neutral4',
+ surfaceContainerLow: 'neutral10',
+ surfaceContainer: 'neutral12',
+ surfaceContainerHigh: 'neutral17',
+ surfaceContainerHighest: 'neutral22',
+ surfaceVariant: 'neutralVariant30',
+ background: 'neutral6',
+ onSurface: 'neutral100',
+ onSurfaceVariant: 'neutralVariant90',
+ onBackground: 'neutral100',
+ outline: 'neutralVariant70',
+ outlineVariant: 'neutralVariant50',
+ inverseSurface: 'neutral90',
+ inverseOnSurface: 'neutral20',
+ inversePrimary: 'primary30',
+ primaryFixed: 'primary90',
+ primaryFixedDim: 'primary80',
+ onPrimaryFixed: 'primary10',
+ onPrimaryFixedVariant: 'primary30',
+ secondaryFixed: 'secondary90',
+ secondaryFixedDim: 'secondary80',
+ onSecondaryFixed: 'secondary10',
+ onSecondaryFixedVariant: 'secondary30',
+ tertiaryFixed: 'tertiary90',
+ tertiaryFixedDim: 'tertiary80',
+ onTertiaryFixed: 'tertiary10',
+ onTertiaryFixedVariant: 'tertiary30',
+ shadow: 'neutral0',
+ scrim: 'neutral0',
+ },
+ high: {
+ primary: 'primary95',
+ onPrimary: 'primary0',
+ primaryContainer: 'primary80',
+ onPrimaryContainer: 'primary0',
+ secondary: 'secondary95',
+ onSecondary: 'secondary0',
+ secondaryContainer: 'secondary80',
+ onSecondaryContainer: 'secondary0',
+ tertiary: 'tertiary95',
+ onTertiary: 'tertiary0',
+ tertiaryContainer: 'tertiary80',
+ onTertiaryContainer: 'tertiary0',
+ error: 'error95',
+ onError: 'error0',
+ errorContainer: 'error80',
+ onErrorContainer: 'error0',
+ surface: 'neutral6',
+ surfaceDim: 'neutral6',
+ surfaceBright: 'neutral24',
+ surfaceContainerLowest: 'neutral4',
+ surfaceContainerLow: 'neutral10',
+ surfaceContainer: 'neutral12',
+ surfaceContainerHigh: 'neutral17',
+ surfaceContainerHighest: 'neutral22',
+ surfaceVariant: 'neutralVariant30',
+ background: 'neutral6',
+ onSurface: 'neutral100',
+ onSurfaceVariant: 'neutralVariant95',
+ onBackground: 'neutral100',
+ outline: 'neutralVariant80',
+ outlineVariant: 'neutralVariant60',
+ inverseSurface: 'neutral90',
+ inverseOnSurface: 'neutral20',
+ inversePrimary: 'primary20',
+ primaryFixed: 'primary90',
+ primaryFixedDim: 'primary80',
+ onPrimaryFixed: 'primary10',
+ onPrimaryFixedVariant: 'primary20',
+ secondaryFixed: 'secondary90',
+ secondaryFixedDim: 'secondary80',
+ onSecondaryFixed: 'secondary10',
+ onSecondaryFixedVariant: 'secondary20',
+ tertiaryFixed: 'tertiary90',
+ tertiaryFixedDim: 'tertiary80',
+ onTertiaryFixed: 'tertiary10',
+ onTertiaryFixedVariant: 'tertiary20',
+ shadow: 'neutral0',
+ scrim: 'neutral0',
+ },
},
};
const elevationToTone: Record<
'light' | 'dark',
- Record, PaletteKey>>
+ Record, PaletteKey>
> = {
light: {
- standard: {
- level1: 'neutral96',
- level2: 'neutral94',
- level3: 'neutral92',
- level4: 'neutral92',
- level5: 'neutral90',
- },
+ level1: 'neutral96',
+ level2: 'neutral94',
+ level3: 'neutral92',
+ level4: 'neutral92',
+ level5: 'neutral90',
},
dark: {
- standard: {
- level1: 'neutral10',
- level2: 'neutral12',
- level3: 'neutral17',
- level4: 'neutral17',
- level5: 'neutral22',
- },
+ level1: 'neutral10',
+ level2: 'neutral12',
+ level3: 'neutral17',
+ level4: 'neutral17',
+ level5: 'neutral22',
},
};
+/** 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. */
export function buildScheme(
palette: Palette,
- opts: { mode: 'light' | 'dark'; contrast?: Contrast }
+ opts: { mode: 'light' | 'dark'; contrast?: ContrastLevel }
): ThemeColors {
const contrast = opts.contrast ?? 'standard';
const tones = roleToTone[opts.mode][contrast];
- const elevTones = elevationToTone[opts.mode][contrast];
+ const elevTones = elevationToTone[opts.mode];
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const mapped = Object.fromEntries(
@@ -161,10 +367,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/types/theme.ts b/src/theme/types/theme.ts
index a4ce2288ae..109d7575e1 100644
--- a/src/theme/types/theme.ts
+++ b/src/theme/types/theme.ts
@@ -6,6 +6,8 @@ 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;
animation: {