Skip to content

feat(theme): Add medium and high contrast theme - #5100

Open
hristototov wants to merge 10 commits into
mainfrom
hristototov/medium-and-high-contrast-theme
Open

hristototov wants to merge 10 commits into
mainfrom
hristototov/medium-and-high-contrast-theme

Conversation

@hristototov

@hristototov hristototov commented Sep 3, 2026 •

Copy link
Copy Markdown
Collaborator

Motivation

Paper only ships the MD3 standard contrast level. MD3 defines two raised levels, medium and high, for accessibility. They increase the contrast between text and its background so the UI stays readable for people with low vision and in bright light. Without them, apps built with Paper have no supported way to meet the raised contrast ratios.

This adds four exported themes, used like the existing ones:

  • MediumContrastLightTheme and MediumContrastDarkTheme
  • HighContrastLightTheme and HighContrastDarkTheme
<PaperProvider theme={HighContrastLightTheme}>
  <App />
</PaperProvider>

LightTheme and DarkTheme are unchanged.

There are no dynamic contrast variants. On Android 14 (API 34) and newer, DynamicLightTheme and DynamicDarkTheme already follow the system Color contrast setting. Apps that need a fixed raised level use the static contrast themes.

The schemes map each color role to a tone of the reference palette. Tones for the baseline #6750A4 seed:

scheme primary primaryContainer onSurfaceVariant
light / standard 40 90 30
light / medium 30 40 30
light / high 20 30 20
dark / standard 80 30 80
dark / medium 90 70 90
dark / high 95 80 95

Related issue

Closes #5092

Screenshots / Videos

android-contrast.mov
ios-contrast.mov

Test plan

In the example app, open the drawer and find Contrast under Preferences.

  1. Switch between Standard, Medium and High. The colors change at each step.
  2. Toggle Dark Theme and repeat. All six combinations render with no missing colors.
  3. Select the Inbox item and switch to High. The "44" counter stays readable in light and dark.
  4. On an iPhone-sized screen, check that the Standard, Medium and High labels are not cut off.
  5. On Android 12 or newer, turn on "Use Dynamic Theme". The Contrast control is hidden and the app uses the wallpaper colors. On Android 14 or newer, change the system Color contrast setting and check that the app follows it.
  6. Restart the app. The selected level is restored.

@hristototov hristototov self-assigned this Sep 3, 2026
@hristototov hristototov added the v6 label Sep 3, 2026
@hristototov
hristototov requested review from adrcotfas and satya164 and removed request for satya164 September 3, 2026 11:38
Comment thread src/theme/tokens/sys/color.ts Outdated
Comment on lines +14 to +15
/** Only `standard` uses the reference palette steps. The other levels need
* tones that are not in the palette, so they live in `./contrastSchemes`. */

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.

These don't match MD3's published contrast schemes. On the color roles page, light/high primary is md.ref.palette.primary20 (#381E72), but this ships #312259; secondary, outline, outline-variant and on-surface-variant are off too, and surface-container isn't meant to change with contrast at all. Every tone MD3 asks for is already a key of ref/palette.ts - could roleToTone just gain medium and high maps, the way the old // extend with 'medium' | 'high' when those ship comment planned?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

updated - I've removed the generator and hand-written the medium and high maps as you suggested.

Comment thread src/core/PaperProvider.tsx Outdated
return {
...base,
...props.theme,
contrast: level,

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.

Passing a theme that has its own colors silently overrides the contrast scheme, but theme.contrast still reports the level you asked for - so useTheme().contrast says "high" while the colours on screen are standard. Worth reconciling, given the docs tell people to read the level back off the theme.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch. The behaviour is intentional but was undocumented, which is the actual problem: contrast records which scheme was selected, and anything passed via theme.colors is layered on top, so an override survives a contrast change.

I've added code comments making that explicit.

Comment thread example/src/index.tsx Outdated
? 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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

updated

@JKobrynski

Copy link
Copy Markdown
Collaborator

And we've got conflicts

Hristo Totov added 2 commits September 6, 2026 19:24
…into hristototov/medium-and-high-contrast-theme

# Conflicts:
#	src/core/PaperProvider.tsx
#	src/index.tsx
Comment thread src/theme/tokens/sys/color.ts Outdated
Comment on lines +159 to +161
primaryFixedDim: 'primary80',
onPrimaryFixed: 'primary10',
onPrimaryFixedVariant: 'primary30',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I get a contrast ratio of 5.47:1 for onPrimaryFixedVariant (#4F378B) on primaryFixedDim (#D0BCFF), below the documented 7:1 target for high contrast. The dark/high mapping at lines 311-313 uses the same values. Could we adjust this pair and include it in the contrast assertions? CONTRAST_PAIRS currently checks onPrimaryFixed on primaryFixed, so it misses this combination.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

updated

Comment thread src/theme/tokens/sys/color.ts Outdated
Comment on lines +163 to +165
secondaryFixedDim: 'secondary80',
onSecondaryFixed: 'secondary10',
onSecondaryFixedVariant: 'secondary30',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I get 5.45:1 for onSecondaryFixedVariant (#4A4458) on secondaryFixedDim (#CCC2DC), below the documented 7:1 high-contrast target. The dark/high mapping at lines 315-317 has the same issue. Could we adjust this pair and cover it in the contrast assertions as well?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

updated

Comment thread src/theme/tokens/sys/color.ts Outdated
Comment on lines +167 to +169
tertiaryFixedDim: 'tertiary80',
onTertiaryFixed: 'tertiary10',
onTertiaryFixedVariant: 'tertiary30',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I get 5.49:1 for onTertiaryFixedVariant (#633B48) on tertiaryFixedDim (#EFB8C8), below the documented 7:1 high-contrast target. The dark/high mapping at lines 319-321 has the same issue. Could we adjust this pair and cover it in the contrast assertions as well? The test that requires all fixed colors to stay unchanged across contrast levels would also need to be revised.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

updated

@satya164 satya164 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Any additional themes should just be exported theme objects, e.g.:

  • MediumContrastLightTheme
  • MediumContrastDarkTheme
  • HighContrastLightTheme
  • HighContrastDarkTheme

Similarly there can be variants for dynamic themes.

It's a much simpler concept since we already export themes and accept theme prop, and there's no need for additional helpers and utilities. "Building" a theme object with createTheme({ dark: true, contrast: 'high' }) instead of just importing HighContrastDarkTheme is unnecessary indirection.

Hristo Totov added 2 commits September 10, 2026 11:57
…into hristototov/medium-and-high-contrast-theme

# Conflicts:
#	src/core/PaperProvider.tsx
#	src/theme/provider.tsx
@hristototov

Copy link
Copy Markdown
Collaborator Author

Any additional themes should just be exported theme objects

Done. Removed createTheme, getDynamicTheme, isDynamicColorSupportedAtContrast, the contrast prop, and the contrast field on Theme. PaperProvider is back to its state on main.

Now exported:

  • MediumContrastLightTheme / MediumContrastDarkTheme
  • HighContrastLightTheme / HighContrastDarkTheme
  • MediumContrastDynamicLightTheme / HighContrastDynamicLightTheme and the dark equivalents

…into hristototov/medium-and-high-contrast-theme
@satya164
satya164 requested a balanced review from Copilot September 15, 2026 20:32

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…into hristototov/medium-and-high-contrast-theme
@satya164

Copy link
Copy Markdown
Member

Review by Claude

iOS

  1. The Contrast control cuts off "Standard". Open the drawer on an iPhone 17. The first segment shows "Stand...". It should show the full label. Each segment is about 88pt wide, and the row adds 28pt padding on both sides. (via Claude, verify if it makes sense)

    iOS, light theme, high contrast: Stand... label and dark 44 on the active Inbox item

  2. The "44" on the active Inbox item is hard to read at medium and high contrast. Open the drawer, select High, and toggle Dark Theme. In light it is black on secondaryContainer (2.26:1), in dark it is white on secondaryContainer (1.70:1). It should use the content color that Drawer.Item passes to right, like the "Starred" item does. The example at example/src/DrawerItems.tsx line 28 renders <Text variant="labelLarge">44</Text>, which falls back to onSurface. At standard contrast this works only because the container and the surface are close in tone. Fix: right: ({ color }) => <Text variant="labelLarge" style={{ color }}>44</Text>. (via Claude, verify if it makes sense)

    iOS, dark theme, high contrast: white 44 on a light container

Android

  1. With "Use Dynamic Theme" on, Medium and High drop the wallpaper colors and show the baseline purple scheme. Open the drawer, keep "Use Dynamic Theme" on, and select High. The *Dynamic* contrast themes are aliases of the static themes. See the inline comment on DynamicTheme.android.tsx. The "44" from finding 2 shows here too. (via Claude, verify if it makes sense)

    Android, dark theme, dynamic theme on, high contrast: static colors and white 44

Web

  1. The "44" from finding 2 has the same low contrast on web at high contrast, in light and dark. (via Claude, verify if it makes sense)

    Web, light theme, high contrast: black 44 on a dark container
    Web, dark theme, high contrast: white 44 on a light container

@satya164 satya164 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Review by Claude

  • The "44" on the active Inbox item in the example drawer drops to 1.70:1 (dark, high) and 2.26:1 (light, high) on every platform. example/src/DrawerItems.tsx line 28 renders it without the color that Drawer.Item passes to right. Details and screenshots: #5100 (comment)
  • The PR description is out of date. It still describes the contrast prop on PaperProvider, scripts/generate-contrast-tokens.ts and PaperProvider.test.tsx, which the later commits removed. The tone table lists generated values (for example 26.7 for light medium primary) that the hand-written maps no longer use (tone 30). (via Claude, verify if it makes sense)

Comment on lines +493 to +505
/**
* 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';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

These four exports are aliases of the static themes on every platform, so HighContrastDynamicLightTheme is the same object as HighContrastLightTheme. An app that picks the dynamic variant loses the wallpaper colors and gets the baseline purple scheme with no signal. In the example app, "Use Dynamic Theme" on plus High shows the static scheme:

Android, dark theme, dynamic theme on, high contrast

The comment is also not accurate for API 34+. There, buildDynamicColors reads the system_*_light / system_*_dark role resources, and Android builds those from the contrast level the user picks in the system Color contrast setting (UiModeManager.getContrast(), API 34). So DynamicLightTheme already follows the user's contrast choice there, and an app cannot ask the system for a different level.

Suggest removing the four *ContrastDynamic* exports here, in DynamicTheme.tsx and in schemes/index.ts, and changing the "Contrast and dynamic colors" docs section to say that DynamicLightTheme and DynamicDarkTheme follow the system contrast setting on API 34+, and that apps that need a fixed raised level use the static contrast themes. (via Claude, verify if it makes sense)

shadow: 'neutral0',
scrim: 'neutral0',
},
medium: {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The hand-written medium and high maps differ from Material Color Utilities, the library that Material Theme Builder and Android dynamic color use. I compared the tones with SchemeTonalSpot from @material/material-color-utilities@0.3.0, seed #6750A4, contrast level 0.5 and 1.0. At contrast 0 it matches the standard maps here except a few on*Container roles, so the comparison holds. Tones that differ by more than 3:

scheme role PR MCU
light medium primary / secondary / tertiary / error 30 23
light medium *Container 40 46
light medium onSurface 10 5
light medium onSurfaceVariant 30 23
light medium outlineVariant 60 46
light medium surfaceContainerHighest 90 84
light high onSurfaceVariant 20 0
light high outlineVariant 40 31
light high surfaceContainer / surfaceContainerHighest 94 / 90 90 / 80
light medium / high primaryFixed 90 46 / 31
light medium / high onPrimaryFixed 10 100
dark medium *Container 70 60
dark high outline / outlineVariant 80 / 60 95 / 79
dark high surfaceContainerHighest 22 30

MCU changes the surface containers, surfaceDim / surfaceBright and the *Fixed roles with contrast, and the doc comment above and the "keeps the fixed surfaces the same at every contrast level" test say the opposite. Apps that also use Theme Builder output or Android colors get different values for the same level. Could the tones come from MCU (or a Theme Builder export for the baseline seed) so they match the spec, with the tests adjusted to match? (via Claude, verify if it makes sense)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed against MCU 0.3.0, but these maps follow @JKobrynski's earlier request to use palette tones from the MD3 roles page, which differ from MCU. @JKobrynski @satya164, which source should we follow?

Comment thread src/theme/schemes/base.ts Outdated
import type { Theme } from '../types';

type ThemeDefaults = Omit<Theme, 'dark' | 'colors'>;
type ThemeDefaults = Omit<Theme, 'dark' | 'colors' | 'contrast'>;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Theme has no contrast field after the latest commits, so 'contrast' in this Omit does nothing. Revert it:

Suggested change
type ThemeDefaults = Omit<Theme, 'dark' | 'colors' | 'contrast'>;
type ThemeDefaults = Omit<Theme, 'dark' | 'colors'>;

(via Claude, verify if it makes sense)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

updated

Comment thread example/src/DrawerItems.tsx Outdated
flexDirection: 'column',
alignItems: 'stretch',
gap: 12,
paddingHorizontal: 28,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

On an iPhone 17 the drawer is too narrow for three segments plus 28pt padding on both sides, so "Standard" shows as "Stand...":

iOS, light theme, high contrast: Stand... label

With 16pt padding all three labels fit. To keep the "Contrast" label aligned with the other rows, give that Text paddingHorizontal: 12.

Suggested change
paddingHorizontal: 28,
paddingHorizontal: 16,

(via Claude, verify if it makes sense)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

updated

Hristo Totov added 2 commits October 24, 2026 11:30
@hristototov

Copy link
Copy Markdown
Collaborator Author
  • The "44" on the active Inbox item in the example drawer drops to 1.70:1 (dark, high) and 2.26:1 (light, high) on every platform. example/src/DrawerItems.tsx line 28 renders it without the color that Drawer.Item passes to right. Details and screenshots: #5100 (comment)

Fixed. The Inbox "44" now uses the color that Drawer.Item passes to right, the same way "Starred" does, so it stays readable at every contrast level.

  • The PR description is out of date. It still describes the contrast prop on PaperProvider, scripts/generate-contrast-tokens.ts and PaperProvider.test.tsx, which the later commits removed. The tone table lists generated values (for example 26.7 for light medium primary) that the hand-written maps no longer use (tone 30). (via Claude, verify if it makes sense)

updated

@callstack callstack deleted a comment from github-actions Bot Oct 24, 2026
@hristototov
hristototov requested a review from satya164 October 24, 2026 08:44

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add MD3 medium and high contrast levels

4 participants