Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions docs/6.x/docs/guides/theming.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 (
<PaperProvider contrast="high">
<App />
</PaperProvider>
);
}
```

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.
Expand Down
23 changes: 23 additions & 0 deletions example/src/DrawerItems.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
Drawer,
Palette,
Portal,
SegmentedButtons,
Switch,
Text,
TouchableRipple,
Expand Down Expand Up @@ -105,9 +106,11 @@ function DrawerItems() {
toggleCollapsed,
toggleCustomFont,
toggleRippleEffect,
setContrast,
customFontLoaded,
rippleEffectEnabled,
collapsed,
contrast,
rtl: isRTL,
theme: { dark: isDarkTheme },
shouldUseDynamicTheme,
Expand Down Expand Up @@ -192,6 +195,20 @@ function DrawerItems() {
</View>
</TouchableRipple>

<View style={[styles.preference, styles.contrastPreference]}>
<Text variant="labelLarge">Contrast</Text>
<SegmentedButtons
value={contrast}
onValueChange={(value) => setContrast(value)}
density="small"
buttons={[
{ value: 'standard', label: 'Standard' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
]}
/>
</View>

<TouchableRipple onPress={_handleToggleRTL}>
<View style={[styles.preference, styles.v3Preference]}>
<Text variant="labelLarge">RTL</Text>
Expand Down Expand Up @@ -278,6 +295,12 @@ const styles = StyleSheet.create({
height: 56,
paddingHorizontal: 28,
},
contrastPreference: {
flexDirection: 'column',
alignItems: 'stretch',
gap: 12,
paddingHorizontal: 28,
},
badge: {
alignSelf: 'center',
},
Expand Down
4 changes: 3 additions & 1 deletion example/src/PreferencesContext.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down
34 changes: 20 additions & 14 deletions example/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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';
Expand Down Expand Up @@ -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<ContrastLevel>('standard');

const theme =
dynamicThemeSupported && shouldUseDynamicTheme
? isDarkMode
? DynamicDarkTheme
: DynamicLightTheme
: isDarkMode
? DarkTheme
: LightTheme;
? getDynamicTheme(isDarkMode, contrast)
: createTheme({ dark: isDarkMode, contrast });

@JKobrynski JKobrynski Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This builds a new theme object on every render, so the provider rebuilds the theme and every themed component re-renders with it. getTheme(isDarkMode, contrast) returns the cached one worth using that here?


const direction = rtl ? 'rtl' : 'ltr';

Expand All @@ -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
Expand All @@ -145,6 +147,7 @@ export default function PaperExample() {
JSON.stringify({
theme: isDarkMode ? 'dark' : 'light',
rtl,
contrast,
})
);
} catch (e) {
Expand All @@ -165,7 +168,7 @@ export default function PaperExample() {
};

void savePrefs();
}, [direction, isDarkMode, isReady, rtl]);
}, [contrast, direction, isDarkMode, isReady, rtl]);

const preferences = React.useMemo(
() => ({
Expand All @@ -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,
Expand All @@ -187,6 +192,7 @@ export default function PaperExample() {
rtl,
theme,
collapsed,
contrast,
customFontLoaded,
shouldUseDynamicTheme,
rippleEffectEnabled,
Expand All @@ -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);
Expand Down
62 changes: 28 additions & 34 deletions example/utils/themes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
Expand Down
Loading