Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
f292d31
Add data-color-scheme attribute for color mode scheme derivation
danielguillan Apr 29, 2026
82aa674
Update token formatter to support data-color-scheme and auto mode
danielguillan Apr 30, 2026
33c3496
Add changeset
danielguillan Apr 30, 2026
5fe073a
Cleanup
danielguillan Apr 30, 2026
bcdeeb2
Fixes
danielguillan Apr 30, 2026
7f64e35
Cleanup
danielguillan Apr 30, 2026
c026d32
Add data-color-scheme attribute for color mode scheme derivation
danielguillan Apr 29, 2026
d8361a1
Update token formatter to support data-color-scheme and auto mode
danielguillan Apr 30, 2026
9d135bc
Add changeset
danielguillan Apr 30, 2026
28bcbc0
Cleanup
danielguillan Apr 30, 2026
7b494eb
Fixes
danielguillan Apr 30, 2026
a3820b7
Cleanup
danielguillan Apr 30, 2026
966eb0a
Remove color-scheme in component scope
danielguillan May 4, 2026
b985088
Merge branch 'danielguillan/color-scheme-attribute' of https://github…
danielguillan May 4, 2026
b68c93c
Add ThemeProvider story for derived themes
danielguillan May 5, 2026
8db6c44
Update snapshots
danielguillan May 5, 2026
82c5c7c
Merge branch 'main' into danielguillan/color-scheme-attribute
danielguillan May 5, 2026
632456d
Update changeset
danielguillan May 5, 2026
a13a336
Address Copilot feedback
danielguillan May 8, 2026
b930c6f
Merge branch 'main' into danielguillan/color-scheme-attribute
danielguillan May 8, 2026
1265864
Merge branch 'main' into danielguillan/color-scheme-attribute
danielguillan May 11, 2026
9a8794a
Fix
danielguillan May 11, 2026
3df3a35
Merge branch 'main' into danielguillan/color-scheme-attribute
danielguillan Jun 1, 2026
513a540
Merge remote-tracking branch 'origin/main' into danielguillan/color-s…
Copilot Jul 16, 2026
3eba10c
Merge remote-tracking branch 'origin/main' into danielguillan/color-s…
Copilot Jul 31, 2026
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
6 changes: 6 additions & 0 deletions .changeset/lazy-wolves-win.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@primer/react-brand': minor
'@primer/brand-primitives': patch
---

`ThemeProvider` now supports derived color modes. You can pass modes like `"dark_dimmed"` or `"light_high_contrast"` and the right light or dark color tokens will apply. No new themes are added; derived modes inherit from the existing `light` and `dark` token sets.
12 changes: 10 additions & 2 deletions packages/design-tokens/src/formats/color-mode-attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ function colorModeAttributes({dictionary, file, options}) {

const renderLightMode = () =>
`:root,
[data-color-mode=${defaultMode}] {
[data-color-mode="${defaultMode}"],
[data-color-scheme="light"] {
${allTokens
.map(createPropertyFormatter({outputReferences, dictionary, format: 'css', formatter: {}, themeable: false}))
.filter(function (strVal) {
Expand Down Expand Up @@ -64,8 +65,14 @@ function colorModeAttributes({dictionary, file, options}) {

const {allTokens: newAllTokens} = newDictionary

let selector = `[data-color-mode="${mode}"]`

if (mode === 'dark') {
selector = `[data-color-mode="${mode}"], [data-color-scheme="dark"]`
}

return `
[data-color-mode="${mode}"] {
${selector} {

${newAllTokens
.map(replaceWithAlternateModeValue)
Expand All @@ -86,6 +93,7 @@ function colorModeAttributes({dictionary, file, options}) {

}\n`
}

const template = `
${fileHeader({file})}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,15 +133,17 @@ describe('Design tokens', () => {

const expectedOutput = `
:root,
[data-color-mode="light"] {
[data-color-mode="light"],
[data-color-scheme="light"] {
--brand-text-color-example: gray;
}

[data-color-mode="light_high_contrast"] {
--brand-text-color-example: black;
}

[data-color-mode="dark"] {
[data-color-mode="dark"],
[data-color-scheme="dark"] {
--brand-text-color-example: white;
}
`.trim()
Expand Down Expand Up @@ -460,13 +462,15 @@ describe('Design tokens', () => {

const expectedOutput = `
:root,
[data-color-mode="light"] {
[data-color-mode="light"],
[data-color-scheme="light"] {
--base-color-scale-gray-0: #f6f8fa;
--base-color-scale-gray-1: #eaeef2;
--base-color-scale-gray-2: #b1bac4;
}

[data-color-mode="dark"] {
[data-color-mode="dark"],
[data-color-scheme="dark"] {
--base-color-scale-gray-0: #f0f6fc;
--base-color-scale-gray-1: #c9d1d9;
--base-color-scale-gray-2: #b1bac4;
Expand All @@ -476,7 +480,7 @@ describe('Design tokens', () => {
--base-color-scale-gray-0: #f0f6fc;
--base-color-scale-gray-1: #c9d1d9;
--base-color-scale-gray-2: #b1bac4;
}
}
`.trim()

// remove timestamp and trim
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/Accordion/Accordion.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ details[open] > .Accordion__summary .Accordion__summary-toggleIcon {
display: block;
}

[data-color-mode='dark'] .Accordion__content {
:is([data-color-mode='dark'], [data-color-scheme='dark']) .Accordion__content {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: auto;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/Card/Card.stories.shared.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
var(--brand-color-canvas-default);
}

[data-color-mode='dark'] .gridColumnArrowHover .gridItem:is(:hover, :focus-within) {
:is([data-color-mode='dark'], [data-color-scheme='dark']) .gridColumnArrowHover .gridItem:is(:hover, :focus-within) {
background: linear-gradient(180deg, rgba(242, 245, 243, 0.1) 0%, rgba(242, 245, 243, 0) 100%),
var(--brand-color-canvas-default);
}
Expand Down
7 changes: 4 additions & 3 deletions packages/react/src/Card/CardSkewEffect.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, {useRef, useEffect, PropsWithChildren} from 'react'
import {clsx} from 'clsx'

import {useTheme} from '../'
import {getColorScheme, useTheme} from '../'
import {BaseProps} from '../component-helpers'

import styles from './Card.module.css'
Expand All @@ -21,11 +21,12 @@ export const CardSkewEffect = ({
...rest
}: PropsWithChildren<CardSkewEffectProps>) => {
const {colorMode} = useTheme()
const colorScheme = getColorScheme(colorMode)
const boundingRef = useRef<HTMLDivElement | null>(null)
const torchRef = useRef<HTMLDivElement | null>(null)

useEffect(() => {
if (colorMode === 'light' || disableSkew) return
if (colorScheme === 'light' || disableSkew) return

const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)')

Expand Down Expand Up @@ -70,7 +71,7 @@ export const CardSkewEffect = ({
torchElement?.removeEventListener('mousemove', handleMouseMove)
motionQuery.removeEventListener('change', handleReducedMotionChange)
}
}, [disableSkew, perspective, colorMode])
}, [disableSkew, perspective, colorScheme])

return (
<div ref={boundingRef} className={clsx(styles['Card--skew'], className)} {...rest}>
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/Heading/Heading.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
--condensed-stretch: 90%;
}

[data-color-mode='dark'] .Heading {
:is([data-color-mode='dark'], [data-color-scheme='dark']) .Heading {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
Expand Down
16 changes: 16 additions & 0 deletions packages/react/src/Hero/Hero.stories.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,22 @@
aspect-ratio: 16 / 9;
}

/**
* Examples
* TODO: Replace with functional token when final color is confirmed
*/

.heroWithCardsExample {
--customCardsBackgroundColor: transparent;
}
:is([data-color-mode='light'], [data-color-scheme='light']) .heroWithCardsExample {
--customCardsBackgroundColor: var(--base-color-scale-gray-1);
}
:is([data-color-mode='dark'], [data-color-scheme='dark']) .heroWithCardsExample {
--customCardsBackgroundColor: var(--base-color-scale-gray-9);
}


/* Custom input with ActionMenu and code copying */

.customInputExample {
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/InlineLink/InlineLink.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
color: var(--brand-InlineLink-color-pressed);
}

[data-color-mode='dark'] .InlineLink {
:is([data-color-mode='dark'], [data-color-scheme='dark']) .InlineLink {
-webkit-font-smoothing: subpixel-antialiased;
-moz-osx-font-smoothing: auto;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/Label/Label.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
text-align: center;
}

[data-color-mode='dark'] .Label {
:is([data-color-mode='dark'], [data-color-scheme='dark']) .Label {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: auto;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@
height: auto;
}

[data-color-mode='light'] .Footer__social-icon {
:is([data-color-mode='light'], [data-color-scheme='light']) .Footer__social-icon {
filter: brightness(95%) saturate(100%);
}

Expand Down
12 changes: 12 additions & 0 deletions packages/react/src/MinimalFooter/MinimalFooter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,18 @@ describe('MinimalFooter', () => {
)
})

it('uses dark logo fill for derived dark color modes', () => {
const {container} = render(
<ThemeProvider colorMode="dark_dimmed">
<MinimalFooter />
</ThemeProvider>,
)

const logo = container.querySelector('a[aria-label="GitHub"] svg')

expect(logo).toHaveAttribute('fill', 'white')
})

it('renders social links with visually hidden text', () => {
const {getByText} = render(<MinimalFooter socialLinks={['x']} />)
const hiddenText = getByText('GitHub on X')
Expand Down
5 changes: 3 additions & 2 deletions packages/react/src/MinimalFooter/MinimalFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, {PropsWithChildren} from 'react'
import {clsx} from 'clsx'
import {LogoGithubIcon} from '@primer/octicons-react'

import {ColorModesEnum, Stack, Text, useTheme} from '../'
import {getColorScheme, Stack, Text, useTheme} from '../'
import {BaseProps} from '../component-helpers'

/**
Expand Down Expand Up @@ -235,6 +235,7 @@ type SocialLogomarksProps = {

function SocialLogomarks({socialLinks = socialLinkNames, logoHref}: SocialLogomarksProps) {
const {colorMode} = useTheme()
const colorScheme = getColorScheme(colorMode)

return (
<section className={clsx(styles['Footer__logomarks'])}>
Expand All @@ -252,7 +253,7 @@ function SocialLogomarks({socialLinks = socialLinkNames, logoHref}: SocialLogoma
data-analytics-event='{"category":"Footer","action":"go to home","label":"text:home"}'
aria-label="GitHub"
>
<LogoGithubIcon fill={colorMode === ColorModesEnum.DARK ? 'white' : 'black'} size="medium" />
<LogoGithubIcon fill={colorScheme === 'dark' ? 'white' : 'black'} size="medium" />
</a>
</div>
{socialLinks ? (
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/SubNav/SubNav.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
background-color: var(--brand-SubNav-bgColor);
}

[data-color-mode='dark'] .SubNav {
:is([data-color-mode='dark'], [data-color-scheme='dark']) .SubNav {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/Text/Text.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
font-family: var(--brand-body-fontFamily);
}

[data-color-mode='dark'] .Text--antialiased {
:is([data-color-mode='dark'], [data-color-scheme='dark']) .Text--antialiased {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: auto;
}
Expand Down
73 changes: 68 additions & 5 deletions packages/react/src/ThemeProvider/ThemeProvider.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,54 @@
import React from 'react'
import type {Meta, StoryObj} from '@storybook/react'
import {SunIcon, MoonIcon} from '@primer/octicons-react'
import {ThemeProvider} from '.'
import {useTheme} from '..'
import {Text} from '../Text'

import {ThemeProvider, type ThemeProviderProps} from '.'
import {Box, Text, Stack, useTheme} from '..'
import styles from './ThemeProvider.stories.module.css'

function ActiveColorMode() {
const {colorMode} = useTheme()
return <Text>Active global color mode: {colorMode}</Text>
}

const providerBoxProps = {
backgroundColor: 'default',
borderColor: 'default',
borderRadius: 'xlarge',
borderStyle: 'solid',
borderWidth: 'thin',
padding: 24,
} as const

function Container({children}: {children: React.ReactNode}) {
return <section className={styles['active-color-scheme']}>{children}</section>
}

function ControlsHint() {
return (
<Text as="p" className={styles.hint}>
<Text as="p" variant="muted">
Hint: Use Storybook Controls to alternate between color modes.
</Text>
)
}

const StorybookColorModes = [
'light',
'dark',
'dark_dimmed',
'light_high_contrast',
'dark_high_contrast',
'auto',
] satisfies ThemeProviderProps['colorMode'][]

const meta = {
title: 'Components/ThemeProvider',
component: ThemeProvider,
argTypes: {
colorMode: {
control: 'select',
options: StorybookColorModes,
},
},
} satisfies Meta<typeof ThemeProvider>

export default meta
Expand Down Expand Up @@ -73,3 +95,44 @@ export const Nested: Story = {
</div>
),
}

export const DerivedColorModes: Story = {
args: {
colorMode: 'auto',
},
render: args => (
<Stack gap="normal">
<ThemeProvider colorMode={args.colorMode}>
<Box {...providerBoxProps}>
<Stack gap="normal">
<Text>{`Parent provider: ${args.colorMode}`}</Text>
<ThemeProvider colorMode="dark_dimmed">
<Box {...providerBoxProps}>
<Stack gap="normal">
<Text font="hubot-sans">Nested provider: dark_dimmed</Text>
<Stack direction={{narrow: 'vertical', regular: 'horizontal'}} gap="normal">
<ThemeProvider colorMode="light_high_contrast" style={{flex: '1 1 0', display: 'flex'}}>
<Box {...providerBoxProps} style={{minHeight: '12rem', width: '100%'}}>
<Text font="hubot-sans">Sibling provider: light_high_contrast</Text>
</Box>
</ThemeProvider>
<ThemeProvider colorMode="dark_high_contrast" style={{flex: '1 1 0', display: 'flex'}}>
<Box {...providerBoxProps} style={{minHeight: '12rem', width: '100%'}}>
<Text font="hubot-sans">Sibling provider: dark_high_contrast</Text>
</Box>
</ThemeProvider>
<ThemeProvider colorMode="auto" style={{flex: '1 1 0', display: 'flex'}}>
<Box {...providerBoxProps} style={{minHeight: '12rem', width: '100%'}}>
<Text font="hubot-sans">Sibling provider: auto</Text>
</Box>
</ThemeProvider>
</Stack>
</Stack>
</Box>
</ThemeProvider>
</Stack>
</Box>
</ThemeProvider>
</Stack>
),
}
Loading