diff --git a/src/components/Button/utils.tsx b/src/components/Button/utils.tsx
index 03ce9513ea..a2ff870c46 100644
--- a/src/components/Button/utils.tsx
+++ b/src/components/Button/utils.tsx
@@ -5,7 +5,7 @@ import { tokens } from '../../theme/tokens';
import type { InternalTheme, Theme } from '../../types';
import { splitStyles } from '../../utils/splitStyles';
-const { stateOpacity } = tokens.md.ref;
+const stateOpacity = tokens.md.sys.state.opacity;
export type ButtonMode =
| 'text'
diff --git a/src/components/Checkbox/CheckboxItem.tsx b/src/components/Checkbox/CheckboxItem.tsx
index 9037137e7a..5f68d3ca92 100644
--- a/src/components/Checkbox/CheckboxItem.tsx
+++ b/src/components/Checkbox/CheckboxItem.tsx
@@ -13,7 +13,7 @@ import Checkbox from './Checkbox';
import CheckboxAndroid from './CheckboxAndroid';
import CheckboxIOS from './CheckboxIOS';
import { useInternalTheme } from '../../core/theming';
-import { tokens } from '../../theme/tokens';
+import { getStateLayer } from '../../theme/utils/state';
import type { ThemeProp, TypescaleKey } from '../../types';
import TouchableRipple, {
Props as TouchableRippleProps,
@@ -161,14 +161,10 @@ const CheckboxItem = ({
checkbox = ;
}
- const textColor = theme.colors.onSurface;
const textAlign = isLeading ? 'right' : 'left';
const computedStyle = {
- color: textColor,
- opacity: disabled
- ? tokens.md.ref.stateOpacity.disabled
- : tokens.md.ref.stateOpacity.enabled,
+ ...getStateLayer(theme, 'onSurface', disabled ? 'disabled' : 'enabled'),
textAlign,
} as TextStyle;
diff --git a/src/components/Checkbox/utils.ts b/src/components/Checkbox/utils.ts
index 87362df00c..8a0bccfb85 100644
--- a/src/components/Checkbox/utils.ts
+++ b/src/components/Checkbox/utils.ts
@@ -3,7 +3,7 @@ import type { ColorValue } from 'react-native';
import { tokens } from '../../theme/tokens';
import type { InternalTheme } from '../../types';
-const { stateOpacity } = tokens.md.ref;
+const stateOpacity = tokens.md.sys.state.opacity;
const getAndroidCheckedColor = ({
theme,
diff --git a/src/components/Chip/helpers.tsx b/src/components/Chip/helpers.tsx
index 6090ed45bb..4b0fdf9e06 100644
--- a/src/components/Chip/helpers.tsx
+++ b/src/components/Chip/helpers.tsx
@@ -7,7 +7,7 @@ import type { InternalTheme, Theme } from '../../types';
const md3 = (theme: InternalTheme) => theme as Theme;
-const { stateOpacity } = tokens.md.ref;
+const stateOpacity = tokens.md.sys.state.opacity;
export type ChipAvatarProps = {
style?: StyleProp;
diff --git a/src/components/HelperText/utils.ts b/src/components/HelperText/utils.ts
index 6c7fe16a58..86c39c2e45 100644
--- a/src/components/HelperText/utils.ts
+++ b/src/components/HelperText/utils.ts
@@ -1,8 +1,6 @@
-import { tokens } from '../../theme/tokens';
+import { getStateLayer } from '../../theme/utils/state';
import type { InternalTheme } from '../../types';
-const { stateOpacity } = tokens.md.ref;
-
type BaseProps = {
theme: InternalTheme;
disabled?: boolean;
@@ -11,18 +9,11 @@ type BaseProps = {
export function getTextColor({ theme, disabled, type }: BaseProps) {
if (type === 'error') {
- return { color: theme.colors.error, opacity: stateOpacity.enabled };
- }
-
- if (disabled) {
- return {
- color: theme.colors.onSurfaceVariant,
- opacity: stateOpacity.disabled,
- };
+ return getStateLayer(theme, 'error', 'enabled');
}
-
- return {
- color: theme.colors.onSurfaceVariant,
- opacity: stateOpacity.enabled,
- };
+ return getStateLayer(
+ theme,
+ 'onSurfaceVariant',
+ disabled ? 'disabled' : 'enabled'
+ );
}
diff --git a/src/components/IconButton/utils.ts b/src/components/IconButton/utils.ts
index 284e9efb2d..cbcf5f1051 100644
--- a/src/components/IconButton/utils.ts
+++ b/src/components/IconButton/utils.ts
@@ -3,7 +3,7 @@ import type { ColorValue } from 'react-native';
import { tokens } from '../../theme/tokens';
import type { InternalTheme } from '../../types';
-const { stateOpacity } = tokens.md.ref;
+const stateOpacity = tokens.md.sys.state.opacity;
type IconButtonMode = 'outlined' | 'contained' | 'contained-tonal';
diff --git a/src/components/Menu/utils.ts b/src/components/Menu/utils.ts
index 9d17a863e6..2aae40d2e4 100644
--- a/src/components/Menu/utils.ts
+++ b/src/components/Menu/utils.ts
@@ -2,7 +2,7 @@ import { tokens } from '../../theme/tokens';
import type { InternalTheme } from '../../types';
import type { IconSource } from '../Icon';
-const { stateOpacity } = tokens.md.ref;
+const stateOpacity = tokens.md.sys.state.opacity;
export const MIN_WIDTH = 112;
export const MAX_WIDTH = 280;
diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx
index 7cce27eb3c..d6590881a3 100644
--- a/src/components/Modal.tsx
+++ b/src/components/Modal.tsx
@@ -20,7 +20,7 @@ import { addEventListener } from '../utils/addEventListener';
import { BackHandler } from '../utils/BackHandler/BackHandler';
import useAnimatedValue from '../utils/useAnimatedValue';
-const { scrimAlpha } = tokens.md.ref;
+const scrimAlpha = tokens.md.sys.scrim.alpha;
export type Props = {
/**
diff --git a/src/components/RadioButton/RadioButtonItem.tsx b/src/components/RadioButton/RadioButtonItem.tsx
index 936a2cdf3d..4c6bcb824d 100644
--- a/src/components/RadioButton/RadioButtonItem.tsx
+++ b/src/components/RadioButton/RadioButtonItem.tsx
@@ -15,7 +15,7 @@ import { RadioButtonContext, RadioButtonContextType } from './RadioButtonGroup';
import RadioButtonIOS from './RadioButtonIOS';
import { handlePress, isChecked } from './utils';
import { useInternalTheme } from '../../core/theming';
-import { tokens } from '../../theme/tokens';
+import { getStateLayer } from '../../theme/utils/state';
import type { ThemeProp, TypescaleKey } from '../../types';
import TouchableRipple, {
Props as TouchableRippleProps,
@@ -179,14 +179,10 @@ const RadioButtonItem = ({
radioButton = ;
}
- const textColor = theme.colors.onSurface;
const textAlign = isLeading ? 'right' : 'left';
const computedStyle = {
- color: textColor,
- opacity: disabled
- ? tokens.md.ref.stateOpacity.disabled
- : tokens.md.ref.stateOpacity.enabled,
+ ...getStateLayer(theme, 'onSurface', disabled ? 'disabled' : 'enabled'),
textAlign,
} as TextStyle;
diff --git a/src/components/SegmentedButtons/utils.ts b/src/components/SegmentedButtons/utils.ts
index af12651971..bf56e25139 100644
--- a/src/components/SegmentedButtons/utils.ts
+++ b/src/components/SegmentedButtons/utils.ts
@@ -3,7 +3,7 @@ import { ViewStyle } from 'react-native';
import { tokens } from '../../theme/tokens';
import type { InternalTheme } from '../../types';
-const { stateOpacity } = tokens.md.ref;
+const stateOpacity = tokens.md.sys.state.opacity;
type BaseProps = {
theme: InternalTheme;
diff --git a/src/components/TextInput/Adornment/utils.ts b/src/components/TextInput/Adornment/utils.ts
index 64e5462b74..3a6e819963 100644
--- a/src/components/TextInput/Adornment/utils.ts
+++ b/src/components/TextInput/Adornment/utils.ts
@@ -1,9 +1,10 @@
import type { ColorValue } from 'react-native';
import { tokens } from '../../../theme/tokens';
+import { getStateLayer } from '../../../theme/utils/state';
import type { InternalTheme } from '../../../types';
-const { stateOpacity } = tokens.md.ref;
+const stateOpacity = tokens.md.sys.state.opacity;
type BaseProps = {
theme: InternalTheme;
@@ -11,10 +12,11 @@ type BaseProps = {
};
export function getTextColor({ theme, disabled }: BaseProps) {
- return {
- color: theme.colors.onSurfaceVariant,
- opacity: disabled ? stateOpacity.disabled : stateOpacity.enabled,
- };
+ return getStateLayer(
+ theme,
+ 'onSurfaceVariant',
+ disabled ? 'disabled' : 'enabled'
+ );
}
export function getIconColor({
diff --git a/src/components/TextInput/helpers.tsx b/src/components/TextInput/helpers.tsx
index 0dd1aad7e6..c633314991 100644
--- a/src/components/TextInput/helpers.tsx
+++ b/src/components/TextInput/helpers.tsx
@@ -19,7 +19,7 @@ import type { TextInputLabelProp } from './types';
import { tokens } from '../../theme/tokens';
import type { InternalTheme } from '../../types';
-const { stateOpacity } = tokens.md.ref;
+const stateOpacity = tokens.md.sys.state.opacity;
type PaddingProps = {
height: number | null;
diff --git a/src/components/__tests__/Button.test.tsx b/src/components/__tests__/Button.test.tsx
index 71107089e7..7f631d5dcb 100644
--- a/src/components/__tests__/Button.test.tsx
+++ b/src/components/__tests__/Button.test.tsx
@@ -10,7 +10,7 @@ import { tokens } from '../../theme/tokens';
import Button from '../Button/Button';
import { getButtonColors } from '../Button/utils';
-const { stateOpacity } = tokens.md.ref;
+const stateOpacity = tokens.md.sys.state.opacity;
const styles = StyleSheet.create({
flexing: {
diff --git a/src/components/__tests__/Checkbox/utils.test.tsx b/src/components/__tests__/Checkbox/utils.test.tsx
index 2aa20fd688..f0c809f75e 100644
--- a/src/components/__tests__/Checkbox/utils.test.tsx
+++ b/src/components/__tests__/Checkbox/utils.test.tsx
@@ -4,7 +4,7 @@ import {
getAndroidSelectionControlColor,
getSelectionControlIOSColor,
} from '../../Checkbox/utils';
-const { stateOpacity } = tokens.md.ref;
+const stateOpacity = tokens.md.sys.state.opacity;
describe('getAndroidSelectionControlColor - checkbox color', () => {
it('should return correct disabled color, for theme version 3', () => {
diff --git a/src/components/__tests__/Chip.test.tsx b/src/components/__tests__/Chip.test.tsx
index cf0f89ddba..7b724dd4ac 100644
--- a/src/components/__tests__/Chip.test.tsx
+++ b/src/components/__tests__/Chip.test.tsx
@@ -10,7 +10,7 @@ import { tokens } from '../../theme/tokens';
import Chip from '../Chip/Chip';
import { getChipColors } from '../Chip/helpers';
-const { stateOpacity } = tokens.md.ref;
+const stateOpacity = tokens.md.sys.state.opacity;
it('renders chip with onPress', () => {
const tree = render( {}}>Example Chip).toJSON();
diff --git a/src/components/__tests__/IconButton.test.tsx b/src/components/__tests__/IconButton.test.tsx
index 9702fe499c..0f34aaec38 100644
--- a/src/components/__tests__/IconButton.test.tsx
+++ b/src/components/__tests__/IconButton.test.tsx
@@ -10,7 +10,7 @@ import { tokens } from '../../theme/tokens';
import IconButton from '../IconButton/IconButton';
import { getIconButtonColor } from '../IconButton/utils';
-const { stateOpacity } = tokens.md.ref;
+const stateOpacity = tokens.md.sys.state.opacity;
const styles = StyleSheet.create({
square: {
diff --git a/src/components/__tests__/MenuItem.test.tsx b/src/components/__tests__/MenuItem.test.tsx
index 8b525a5844..2fa3f809bd 100644
--- a/src/components/__tests__/MenuItem.test.tsx
+++ b/src/components/__tests__/MenuItem.test.tsx
@@ -6,7 +6,7 @@ import { tokens } from '../../theme/tokens';
import Menu from '../Menu/Menu';
import { getMenuItemColor } from '../Menu/utils';
-const { stateOpacity } = tokens.md.ref;
+const stateOpacity = tokens.md.sys.state.opacity;
describe('Menu Item', () => {
it('renders menu item', () => {
diff --git a/src/components/__tests__/Modal.test.tsx b/src/components/__tests__/Modal.test.tsx
index f4b37bc564..77f3fe609f 100644
--- a/src/components/__tests__/Modal.test.tsx
+++ b/src/components/__tests__/Modal.test.tsx
@@ -13,7 +13,7 @@ import { LightTheme } from '../../theme/schemes';
import { tokens } from '../../theme/tokens';
import Modal from '../Modal';
-const { scrimAlpha } = tokens.md.ref;
+const scrimAlpha = tokens.md.sys.scrim.alpha;
jest.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ bottom: 44, left: 0, right: 0, top: 37 }),
diff --git a/src/components/__tests__/SegmentedButton.test.tsx b/src/components/__tests__/SegmentedButton.test.tsx
index 94ab9828b1..0904d02829 100644
--- a/src/components/__tests__/SegmentedButton.test.tsx
+++ b/src/components/__tests__/SegmentedButton.test.tsx
@@ -9,7 +9,7 @@ import {
getSegmentedButtonColors,
} from '../SegmentedButtons/utils';
-const { stateOpacity } = tokens.md.ref;
+const stateOpacity = tokens.md.sys.state.opacity;
it('renders segmented button', () => {
const tree = render(
diff --git a/src/components/__tests__/TextInput.test.tsx b/src/components/__tests__/TextInput.test.tsx
index 7890ce3253..97f77833aa 100644
--- a/src/components/__tests__/TextInput.test.tsx
+++ b/src/components/__tests__/TextInput.test.tsx
@@ -16,7 +16,7 @@ import {
} from '../TextInput/helpers';
import TextInput, { Props } from '../TextInput/TextInput';
-const { stateOpacity } = tokens.md.ref;
+const stateOpacity = tokens.md.sys.state.opacity;
const style = StyleSheet.create({
inputStyle: {
diff --git a/src/theme/schemes/DarkTheme.tsx b/src/theme/schemes/DarkTheme.tsx
index dfc8e6b16b..9b7ff60ef4 100644
--- a/src/theme/schemes/DarkTheme.tsx
+++ b/src/theme/schemes/DarkTheme.tsx
@@ -1,12 +1,12 @@
import { themeDefaults } from './base';
import { tokens } from '../tokens';
-import { buildScheme } from '../tokens/sys/color/roles';
+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, tokens.md.ref, { mode: 'dark' }),
+ colors: buildScheme(tokens.md.ref.palette, { mode: 'dark' }),
shapes: defaultShapes,
};
diff --git a/src/theme/schemes/LightTheme.tsx b/src/theme/schemes/LightTheme.tsx
index 35926afde9..42593d5d42 100644
--- a/src/theme/schemes/LightTheme.tsx
+++ b/src/theme/schemes/LightTheme.tsx
@@ -1,12 +1,12 @@
import { themeDefaults } from './base';
import { tokens } from '../tokens';
-import { buildScheme } from '../tokens/sys/color/roles';
+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, tokens.md.ref, { mode: 'light' }),
+ colors: buildScheme(tokens.md.ref.palette, { mode: 'light' }),
shapes: defaultShapes,
};
diff --git a/src/theme/tokens/index.ts b/src/theme/tokens/index.ts
index b0b160b080..3f41b7fec5 100644
--- a/src/theme/tokens/index.ts
+++ b/src/theme/tokens/index.ts
@@ -1,358 +1,22 @@
-import { Platform } from 'react-native';
-
-import type { Font } from '../types';
-
-const ref = {
- palette: {
- primary100: 'rgba(255, 255, 255, 1)',
- primary99: 'rgba(255, 251, 254, 1)',
- primary98: 'rgba(254, 247, 255, 1)',
- primary95: 'rgba(246, 237, 255, 1)',
- primary90: 'rgba(234, 221, 255, 1)',
- primary80: 'rgba(208, 188, 255, 1)',
- primary70: 'rgba(182, 157, 248, 1)',
- primary60: 'rgba(154, 130, 219, 1)',
- primary50: 'rgba(127, 103, 190, 1)',
- primary40: 'rgba(103, 80, 164, 1)',
- primary30: 'rgba(79, 55, 139, 1)',
- primary20: 'rgba(56, 30, 114, 1)',
- primary10: 'rgba(33, 0, 93, 1)',
- primary0: 'rgba(0, 0, 0, 1)',
- secondary100: 'rgba(255, 255, 255, 1)',
- secondary99: 'rgba(255, 251, 254, 1)',
- secondary98: 'rgba(254, 247, 255, 1)',
- secondary95: 'rgba(246, 237, 255, 1)',
- secondary90: 'rgba(232, 222, 248, 1)',
- secondary80: 'rgba(204, 194, 220, 1)',
- secondary70: 'rgba(176, 167, 192, 1)',
- secondary60: 'rgba(149, 141, 165, 1)',
- secondary50: 'rgba(122, 114, 137, 1)',
- secondary40: 'rgba(98, 91, 113, 1)',
- secondary30: 'rgba(74, 68, 88, 1)',
- secondary20: 'rgba(51, 45, 65, 1)',
- secondary10: 'rgba(29, 25, 43, 1)',
- secondary0: 'rgba(0, 0, 0, 1)',
- tertiary100: 'rgba(255, 255, 255, 1)',
- tertiary99: 'rgba(255, 251, 250, 1)',
- tertiary98: 'rgba(255, 248, 248, 1)',
- tertiary95: 'rgba(255, 236, 241, 1)',
- tertiary90: 'rgba(255, 216, 228, 1)',
- tertiary80: 'rgba(239, 184, 200, 1)',
- tertiary70: 'rgba(210, 157, 172, 1)',
- tertiary60: 'rgba(181, 131, 146, 1)',
- tertiary50: 'rgba(152, 105, 119, 1)',
- tertiary40: 'rgba(125, 82, 96, 1)',
- tertiary30: 'rgba(99, 59, 72, 1)',
- tertiary20: 'rgba(73, 37, 50, 1)',
- tertiary10: 'rgba(49, 17, 29, 1)',
- tertiary0: 'rgba(0, 0, 0, 1)',
- neutral100: 'rgba(255, 255, 255, 1)',
- neutral99: 'rgba(255, 251, 255, 1)',
- neutral98: 'rgba(254, 247, 255, 1)',
- neutral96: 'rgba(247, 242, 250, 1)',
- neutral95: 'rgba(245, 239, 247, 1)',
- neutral94: 'rgba(243, 237, 247, 1)',
- neutral92: 'rgba(236, 230, 240, 1)',
- neutral90: 'rgba(230, 224, 233, 1)',
- neutral87: 'rgba(222, 216, 225, 1)',
- neutral80: 'rgba(202, 197, 205, 1)',
- neutral70: 'rgba(174, 169, 177, 1)',
- neutral60: 'rgba(147, 143, 150, 1)',
- neutral50: 'rgba(121, 118, 125, 1)',
- neutral40: 'rgba(96, 93, 100, 1)',
- neutral30: 'rgba(72, 70, 76, 1)',
- neutral24: 'rgba(59, 56, 62, 1)',
- neutral22: 'rgba(54, 52, 59, 1)',
- neutral20: 'rgba(50, 47, 53, 1)',
- neutral17: 'rgba(43, 41, 48, 1)',
- neutral12: 'rgba(33, 31, 38, 1)',
- neutral10: 'rgba(29, 27, 32, 1)',
- neutral6: 'rgba(20, 18, 24, 1)',
- neutral4: 'rgba(15, 13, 19, 1)',
- neutral0: 'rgba(0, 0, 0, 1)',
- neutralVariant100: 'rgba(255, 255, 255, 1)',
- neutralVariant99: 'rgba(255, 251, 254, 1)',
- neutralVariant98: 'rgba(253, 247, 255, 1)',
- neutralVariant95: 'rgba(245, 238, 250, 1)',
- neutralVariant90: 'rgba(231, 224, 236, 1)',
- neutralVariant80: 'rgba(202, 196, 208, 1)',
- neutralVariant70: 'rgba(174, 169, 180, 1)',
- neutralVariant60: 'rgba(147, 143, 153, 1)',
- neutralVariant50: 'rgba(121, 116, 126, 1)',
- neutralVariant40: 'rgba(96, 93, 102, 1)',
- neutralVariant30: 'rgba(73, 69, 79, 1)',
- neutralVariant20: 'rgba(50, 47, 55, 1)',
- neutralVariant10: 'rgba(29, 26, 34, 1)',
- neutralVariant0: 'rgba(0, 0, 0, 1)',
- error100: 'rgba(255, 255, 255, 1)',
- error99: 'rgba(255, 251, 249, 1)',
- error98: 'rgba(255, 248, 247, 1)',
- error95: 'rgba(252, 238, 238, 1)',
- error90: 'rgba(249, 222, 220, 1)',
- error80: 'rgba(242, 184, 181, 1)',
- error70: 'rgba(236, 146, 142, 1)',
- error60: 'rgba(228, 105, 98, 1)',
- error50: 'rgba(220, 54, 46, 1)',
- error40: 'rgba(179, 38, 30, 1)',
- error30: 'rgba(140, 29, 24, 1)',
- error20: 'rgba(96, 20, 16, 1)',
- error10: 'rgba(65, 14, 11, 1)',
- error0: 'rgba(0, 0, 0, 1)',
- },
-
- typeface: {
- brandRegular: Platform.select({
- web: 'Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif',
- ios: 'System',
- default: 'sans-serif',
- }),
- weightRegular: '400' as Font['fontWeight'],
-
- plainMedium: Platform.select({
- web: 'Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif',
- ios: 'System',
- default: 'sans-serif-medium',
- }),
- weightMedium: '500' as Font['fontWeight'],
-
- weightBold: '700' as Font['fontWeight'],
- },
-
- /** State layers opacity
- * @see https://m3.material.io/foundations/interaction/states/state-layers
- */
- stateOpacity: {
- dragged: 0.16,
- pressed: 0.1,
- focused: 0.1,
- hovered: 0.08,
- disabled: 0.38,
- enabled: 1.0,
- },
-
- scrimAlpha: 0.32,
-};
-
-const regularType = {
- fontFamily: ref.typeface.brandRegular,
- letterSpacing: 0,
- fontWeight: ref.typeface.weightRegular,
-};
-
-const mediumType = {
- fontFamily: ref.typeface.plainMedium,
- letterSpacing: 0.15,
- fontWeight: ref.typeface.weightMedium,
-};
-
-const emphasizedMediumType = {
- fontFamily: ref.typeface.plainMedium,
- letterSpacing: 0,
- fontWeight: ref.typeface.weightMedium,
-};
-
-const emphasizedBoldType = {
- fontFamily: ref.typeface.plainMedium,
- letterSpacing: 0,
- fontWeight: ref.typeface.weightBold,
-};
-
-export const typescale = {
- displayLarge: {
- ...regularType,
- letterSpacing: -0.25,
- lineHeight: 64,
- fontSize: 57,
- },
- displayMedium: {
- ...regularType,
- lineHeight: 52,
- fontSize: 45,
- },
- displaySmall: {
- ...regularType,
- lineHeight: 44,
- fontSize: 36,
- },
-
- headlineLarge: {
- ...regularType,
- lineHeight: 40,
- fontSize: 32,
- },
- headlineMedium: {
- ...regularType,
- lineHeight: 36,
- fontSize: 28,
- },
- headlineSmall: {
- ...regularType,
- lineHeight: 32,
- fontSize: 24,
- },
-
- titleLarge: {
- ...regularType,
- lineHeight: 28,
- fontSize: 22,
- },
- titleMedium: {
- ...mediumType,
- lineHeight: 24,
- fontSize: 16,
- },
- titleSmall: {
- ...mediumType,
- letterSpacing: 0.1,
- lineHeight: 20,
- fontSize: 14,
- },
-
- labelLarge: {
- ...mediumType,
- letterSpacing: 0.1,
- lineHeight: 20,
- fontSize: 14,
- },
- labelMedium: {
- ...mediumType,
- letterSpacing: 0.5,
- lineHeight: 16,
- fontSize: 12,
- },
- labelSmall: {
- ...mediumType,
- letterSpacing: 0.5,
- lineHeight: 16,
- fontSize: 11,
- },
-
- bodyLarge: {
- ...mediumType,
- fontWeight: ref.typeface.weightRegular,
- fontFamily: ref.typeface.brandRegular,
- letterSpacing: 0.5,
- lineHeight: 24,
- fontSize: 16,
- },
- bodyMedium: {
- ...mediumType,
- fontWeight: ref.typeface.weightRegular,
- fontFamily: ref.typeface.brandRegular,
- letterSpacing: 0.25,
- lineHeight: 20,
- fontSize: 14,
- },
- bodySmall: {
- ...mediumType,
- fontWeight: ref.typeface.weightRegular,
- fontFamily: ref.typeface.brandRegular,
- letterSpacing: 0.4,
- lineHeight: 16,
- fontSize: 12,
- },
-
- displayLargeEmphasized: {
- ...emphasizedMediumType,
- letterSpacing: -0.25,
- lineHeight: 64,
- fontSize: 57,
- },
- displayMediumEmphasized: {
- ...emphasizedMediumType,
- lineHeight: 52,
- fontSize: 45,
- },
- displaySmallEmphasized: {
- ...emphasizedMediumType,
- lineHeight: 44,
- fontSize: 36,
- },
-
- headlineLargeEmphasized: {
- ...emphasizedMediumType,
- lineHeight: 40,
- fontSize: 32,
- },
- headlineMediumEmphasized: {
- ...emphasizedMediumType,
- lineHeight: 36,
- fontSize: 28,
- },
- headlineSmallEmphasized: {
- ...emphasizedMediumType,
- lineHeight: 32,
- fontSize: 24,
- },
-
- titleLargeEmphasized: {
- ...emphasizedMediumType,
- lineHeight: 28,
- fontSize: 22,
- },
- titleMediumEmphasized: {
- ...emphasizedBoldType,
- letterSpacing: 0.15,
- lineHeight: 24,
- fontSize: 16,
- },
- titleSmallEmphasized: {
- ...emphasizedBoldType,
- letterSpacing: 0.1,
- lineHeight: 20,
- fontSize: 14,
- },
-
- labelLargeEmphasized: {
- ...emphasizedBoldType,
- letterSpacing: 0.1,
- lineHeight: 20,
- fontSize: 14,
- },
- labelMediumEmphasized: {
- ...emphasizedBoldType,
- letterSpacing: 0.5,
- lineHeight: 16,
- fontSize: 12,
- },
- labelSmallEmphasized: {
- ...emphasizedBoldType,
- letterSpacing: 0.5,
- lineHeight: 16,
- fontSize: 11,
- },
-
- bodyLargeEmphasized: {
- ...emphasizedMediumType,
- letterSpacing: 0.5,
- lineHeight: 24,
- fontSize: 16,
- },
- bodyMediumEmphasized: {
- ...emphasizedMediumType,
- letterSpacing: 0.25,
- lineHeight: 20,
- fontSize: 14,
- },
- bodySmallEmphasized: {
- ...emphasizedMediumType,
- letterSpacing: 0.4,
- lineHeight: 16,
- fontSize: 12,
- },
-
- default: {
- ...regularType,
- },
-};
+import { palette } from './ref/palette';
+import { typeface } from './ref/typeface';
+import { state } from './sys/state';
+import { typescale } from './sys/typography';
+/** Material Design token tree: md.ref.* (raw values) and md.sys.* (semantic decisions). */
export const tokens = {
md: {
- ref,
+ ref: {
+ palette,
+ typeface,
+ },
sys: {
typescale,
+ state: state,
+ scrim: { alpha: 0.32 },
},
},
};
-export const Palette = ref.palette;
+export { typescale };
+export const Palette = palette;
diff --git a/src/theme/tokens/ref/palette.ts b/src/theme/tokens/ref/palette.ts
new file mode 100644
index 0000000000..f16b793f66
--- /dev/null
+++ b/src/theme/tokens/ref/palette.ts
@@ -0,0 +1,97 @@
+/** md.ref.palette.* — tonal palette reference tokens. */
+export const palette = {
+ primary100: 'rgba(255, 255, 255, 1)',
+ primary99: 'rgba(255, 251, 254, 1)',
+ primary98: 'rgba(254, 247, 255, 1)',
+ primary95: 'rgba(246, 237, 255, 1)',
+ primary90: 'rgba(234, 221, 255, 1)',
+ primary80: 'rgba(208, 188, 255, 1)',
+ primary70: 'rgba(182, 157, 248, 1)',
+ primary60: 'rgba(154, 130, 219, 1)',
+ primary50: 'rgba(127, 103, 190, 1)',
+ primary40: 'rgba(103, 80, 164, 1)',
+ primary30: 'rgba(79, 55, 139, 1)',
+ primary20: 'rgba(56, 30, 114, 1)',
+ primary10: 'rgba(33, 0, 93, 1)',
+ primary0: 'rgba(0, 0, 0, 1)',
+ secondary100: 'rgba(255, 255, 255, 1)',
+ secondary99: 'rgba(255, 251, 254, 1)',
+ secondary98: 'rgba(254, 247, 255, 1)',
+ secondary95: 'rgba(246, 237, 255, 1)',
+ secondary90: 'rgba(232, 222, 248, 1)',
+ secondary80: 'rgba(204, 194, 220, 1)',
+ secondary70: 'rgba(176, 167, 192, 1)',
+ secondary60: 'rgba(149, 141, 165, 1)',
+ secondary50: 'rgba(122, 114, 137, 1)',
+ secondary40: 'rgba(98, 91, 113, 1)',
+ secondary30: 'rgba(74, 68, 88, 1)',
+ secondary20: 'rgba(51, 45, 65, 1)',
+ secondary10: 'rgba(29, 25, 43, 1)',
+ secondary0: 'rgba(0, 0, 0, 1)',
+ tertiary100: 'rgba(255, 255, 255, 1)',
+ tertiary99: 'rgba(255, 251, 250, 1)',
+ tertiary98: 'rgba(255, 248, 248, 1)',
+ tertiary95: 'rgba(255, 236, 241, 1)',
+ tertiary90: 'rgba(255, 216, 228, 1)',
+ tertiary80: 'rgba(239, 184, 200, 1)',
+ tertiary70: 'rgba(210, 157, 172, 1)',
+ tertiary60: 'rgba(181, 131, 146, 1)',
+ tertiary50: 'rgba(152, 105, 119, 1)',
+ tertiary40: 'rgba(125, 82, 96, 1)',
+ tertiary30: 'rgba(99, 59, 72, 1)',
+ tertiary20: 'rgba(73, 37, 50, 1)',
+ tertiary10: 'rgba(49, 17, 29, 1)',
+ tertiary0: 'rgba(0, 0, 0, 1)',
+ neutral100: 'rgba(255, 255, 255, 1)',
+ neutral99: 'rgba(255, 251, 255, 1)',
+ neutral98: 'rgba(254, 247, 255, 1)',
+ neutral96: 'rgba(247, 242, 250, 1)',
+ neutral95: 'rgba(245, 239, 247, 1)',
+ neutral94: 'rgba(243, 237, 247, 1)',
+ neutral92: 'rgba(236, 230, 240, 1)',
+ neutral90: 'rgba(230, 224, 233, 1)',
+ neutral87: 'rgba(222, 216, 225, 1)',
+ neutral80: 'rgba(202, 197, 205, 1)',
+ neutral70: 'rgba(174, 169, 177, 1)',
+ neutral60: 'rgba(147, 143, 150, 1)',
+ neutral50: 'rgba(121, 118, 125, 1)',
+ neutral40: 'rgba(96, 93, 100, 1)',
+ neutral30: 'rgba(72, 70, 76, 1)',
+ neutral24: 'rgba(59, 56, 62, 1)',
+ neutral22: 'rgba(54, 52, 59, 1)',
+ neutral20: 'rgba(50, 47, 53, 1)',
+ neutral17: 'rgba(43, 41, 48, 1)',
+ neutral12: 'rgba(33, 31, 38, 1)',
+ neutral10: 'rgba(29, 27, 32, 1)',
+ neutral6: 'rgba(20, 18, 24, 1)',
+ neutral4: 'rgba(15, 13, 19, 1)',
+ neutral0: 'rgba(0, 0, 0, 1)',
+ neutralVariant100: 'rgba(255, 255, 255, 1)',
+ neutralVariant99: 'rgba(255, 251, 254, 1)',
+ neutralVariant98: 'rgba(253, 247, 255, 1)',
+ neutralVariant95: 'rgba(245, 238, 250, 1)',
+ neutralVariant90: 'rgba(231, 224, 236, 1)',
+ neutralVariant80: 'rgba(202, 196, 208, 1)',
+ neutralVariant70: 'rgba(174, 169, 180, 1)',
+ neutralVariant60: 'rgba(147, 143, 153, 1)',
+ neutralVariant50: 'rgba(121, 116, 126, 1)',
+ neutralVariant40: 'rgba(96, 93, 102, 1)',
+ neutralVariant30: 'rgba(73, 69, 79, 1)',
+ neutralVariant20: 'rgba(50, 47, 55, 1)',
+ neutralVariant10: 'rgba(29, 26, 34, 1)',
+ neutralVariant0: 'rgba(0, 0, 0, 1)',
+ error100: 'rgba(255, 255, 255, 1)',
+ error99: 'rgba(255, 251, 249, 1)',
+ error98: 'rgba(255, 248, 247, 1)',
+ error95: 'rgba(252, 238, 238, 1)',
+ error90: 'rgba(249, 222, 220, 1)',
+ error80: 'rgba(242, 184, 181, 1)',
+ error70: 'rgba(236, 146, 142, 1)',
+ error60: 'rgba(228, 105, 98, 1)',
+ error50: 'rgba(220, 54, 46, 1)',
+ error40: 'rgba(179, 38, 30, 1)',
+ error30: 'rgba(140, 29, 24, 1)',
+ error20: 'rgba(96, 20, 16, 1)',
+ error10: 'rgba(65, 14, 11, 1)',
+ error0: 'rgba(0, 0, 0, 1)',
+} as const;
diff --git a/src/theme/tokens/ref/typeface.ts b/src/theme/tokens/ref/typeface.ts
new file mode 100644
index 0000000000..e4b92318ec
--- /dev/null
+++ b/src/theme/tokens/ref/typeface.ts
@@ -0,0 +1,22 @@
+import { Platform } from 'react-native';
+
+import type { Font } from '../../types';
+
+/** md.ref.typeface.* — font families and weights. */
+export const typeface = {
+ brandRegular: Platform.select({
+ web: 'Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif',
+ ios: 'System',
+ default: 'sans-serif',
+ }),
+ weightRegular: '400' as Font['fontWeight'],
+
+ plainMedium: Platform.select({
+ web: 'Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif',
+ ios: 'System',
+ default: 'sans-serif-medium',
+ }),
+ weightMedium: '500' as Font['fontWeight'],
+
+ weightBold: '700' as Font['fontWeight'],
+};
diff --git a/src/theme/tokens/sys/color/roles.ts b/src/theme/tokens/sys/color.ts
similarity index 95%
rename from src/theme/tokens/sys/color/roles.ts
rename to src/theme/tokens/sys/color.ts
index a3b3a49695..efdb08cbba 100644
--- a/src/theme/tokens/sys/color/roles.ts
+++ b/src/theme/tokens/sys/color.ts
@@ -1,11 +1,11 @@
import color from 'color';
-import type { ElevationColors, ThemeColors } from '../../../types';
-import { tokens } from '../../index';
+import { state } from './state';
+import type { ElevationColors, ThemeColors } from '../../types';
+import { palette as defaultPalette } from '../ref/palette';
-type Palette = typeof tokens.md.ref.palette;
+type Palette = typeof defaultPalette;
type PaletteKey = keyof Palette;
-type Ref = typeof tokens.md.ref;
/** Roles that map 1:1 to a palette key. Excludes the computed fields. */
type MappedRoles = Omit;
@@ -148,7 +148,6 @@ const elevationToTone: Record<
export function buildScheme(
palette: Palette,
- ref: Ref,
opts: { mode: 'light' | 'dark'; contrast?: Contrast }
): ThemeColors {
const contrast = opts.contrast ?? 'standard';
@@ -165,7 +164,7 @@ export function buildScheme(
return {
...mapped,
stateLayerPressed: color(palette[tones.onSurface])
- .alpha(ref.stateOpacity.pressed)
+ .alpha(state.opacity.pressed)
.rgb()
.string(),
elevation: {
diff --git a/src/theme/tokens/sys/elevation.ts b/src/theme/tokens/sys/elevation.ts
index f02be56f29..488208edd6 100644
--- a/src/theme/tokens/sys/elevation.ts
+++ b/src/theme/tokens/sys/elevation.ts
@@ -1,11 +1,26 @@
// M3 elevation tokens and shadow builder per spec:
// https://m3.material.io/styles/elevation/tokens
-import { Animated, type ColorValue } from 'react-native';
+import {
+ Animated,
+ type ColorValue,
+ type ViewStyle,
+ type Animated as AnimatedNS,
+} from 'react-native';
import { isAnimatedValue } from '../../../utils/animations';
import type { Elevation, ThemeElevation } from '../../types';
+type AnimatedShadowStyle = {
+ shadowColor: ColorValue;
+ shadowOffset: {
+ width: AnimatedNS.Value;
+ height: AnimatedNS.AnimatedInterpolation;
+ };
+ shadowOpacity: AnimatedNS.AnimatedInterpolation;
+ shadowRadius: AnimatedNS.AnimatedInterpolation;
+};
+
export const defaultElevation: ThemeElevation = {
level0: 0,
level1: 1,
@@ -32,10 +47,23 @@ export const shadowLayers = [
},
];
+// eslint-disable-next-line no-redeclare
+export function shadow(elevation: number, shadowColor: ColorValue): ViewStyle;
+// eslint-disable-next-line no-redeclare
+export function shadow(
+ elevation: Animated.Value,
+ shadowColor: ColorValue
+): AnimatedShadowStyle;
+// eslint-disable-next-line no-redeclare
+export function shadow(
+ elevation: number | Animated.Value,
+ shadowColor: ColorValue
+): ViewStyle | AnimatedShadowStyle;
+// eslint-disable-next-line no-redeclare
export function shadow(
elevation: number | Animated.Value = 0,
shadowColor: ColorValue
-) {
+): ViewStyle | AnimatedShadowStyle {
if (isAnimatedValue(elevation)) {
return {
shadowColor,
diff --git a/src/theme/tokens/sys/motion.ts b/src/theme/tokens/sys/motion.ts
index a7c96454f2..878d63e0e9 100644
--- a/src/theme/tokens/sys/motion.ts
+++ b/src/theme/tokens/sys/motion.ts
@@ -2,6 +2,7 @@ import type {
MotionConfig,
MotionDuration,
MotionEasing,
+ RawSpring,
SpringConfig,
} from '../../types';
@@ -93,7 +94,20 @@ export const standardMotion: MotionConfig = {
* ...toRawSpring(theme.motion.spring.fast.spatial),
* useNativeDriver: true,
* });
+ *
+ * // Reanimated
+ * sharedValue.value = withSpring(
+ * target,
+ * toRawSpring(theme.motion.spring.fast.spatial)
+ * );
*/
-export function toRawSpring({ stiffness, damping }: SpringConfig) {
- return { stiffness, damping: damping * 2 * Math.sqrt(stiffness) };
+export function toRawSpring({
+ stiffness,
+ damping: ratio,
+}: SpringConfig): RawSpring {
+ return {
+ stiffness,
+ damping: ratio * 2 * Math.sqrt(stiffness),
+ mass: 1, // as per MD specs
+ };
}
diff --git a/src/theme/tokens/sys/state.ts b/src/theme/tokens/sys/state.ts
new file mode 100644
index 0000000000..d0742351bf
--- /dev/null
+++ b/src/theme/tokens/sys/state.ts
@@ -0,0 +1,18 @@
+/**
+ * md.sys.state.* — interaction-state system tokens.
+ * @see https://m3.material.io/foundations/interaction/states/state-layers
+ */
+export const state = {
+ opacity: {
+ dragged: 0.16,
+ pressed: 0.1,
+ focused: 0.1,
+ hovered: 0.08,
+ disabled: 0.38,
+ enabled: 1.0,
+ },
+ focusIndicator: {
+ thickness: 3,
+ outerOffset: 2,
+ },
+} as const;
diff --git a/src/theme/tokens/sys/typography.ts b/src/theme/tokens/sys/typography.ts
index 2ea3e08c6c..289cbdedef 100644
--- a/src/theme/tokens/sys/typography.ts
+++ b/src/theme/tokens/sys/typography.ts
@@ -1,4 +1,218 @@
import type { Typescale } from '../../types';
-import { typescale } from '../index';
+import { typeface } from '../ref/typeface';
+
+const regularType = {
+ fontFamily: typeface.brandRegular,
+ letterSpacing: 0,
+ fontWeight: typeface.weightRegular,
+};
+
+const mediumType = {
+ fontFamily: typeface.plainMedium,
+ letterSpacing: 0.15,
+ fontWeight: typeface.weightMedium,
+};
+
+const emphasizedMediumType = {
+ fontFamily: typeface.plainMedium,
+ letterSpacing: 0,
+ fontWeight: typeface.weightMedium,
+};
+
+const emphasizedBoldType = {
+ fontFamily: typeface.plainMedium,
+ letterSpacing: 0,
+ fontWeight: typeface.weightBold,
+};
+
+/** md.sys.typescale.* */
+export const typescale = {
+ displayLarge: {
+ ...regularType,
+ letterSpacing: -0.25,
+ lineHeight: 64,
+ fontSize: 57,
+ },
+ displayMedium: {
+ ...regularType,
+ lineHeight: 52,
+ fontSize: 45,
+ },
+ displaySmall: {
+ ...regularType,
+ lineHeight: 44,
+ fontSize: 36,
+ },
+
+ headlineLarge: {
+ ...regularType,
+ lineHeight: 40,
+ fontSize: 32,
+ },
+ headlineMedium: {
+ ...regularType,
+ lineHeight: 36,
+ fontSize: 28,
+ },
+ headlineSmall: {
+ ...regularType,
+ lineHeight: 32,
+ fontSize: 24,
+ },
+
+ titleLarge: {
+ ...regularType,
+ lineHeight: 28,
+ fontSize: 22,
+ },
+ titleMedium: {
+ ...mediumType,
+ lineHeight: 24,
+ fontSize: 16,
+ },
+ titleSmall: {
+ ...mediumType,
+ letterSpacing: 0.1,
+ lineHeight: 20,
+ fontSize: 14,
+ },
+
+ labelLarge: {
+ ...mediumType,
+ letterSpacing: 0.1,
+ lineHeight: 20,
+ fontSize: 14,
+ },
+ labelMedium: {
+ ...mediumType,
+ letterSpacing: 0.5,
+ lineHeight: 16,
+ fontSize: 12,
+ },
+ labelSmall: {
+ ...mediumType,
+ letterSpacing: 0.5,
+ lineHeight: 16,
+ fontSize: 11,
+ },
+
+ bodyLarge: {
+ ...mediumType,
+ fontWeight: typeface.weightRegular,
+ fontFamily: typeface.brandRegular,
+ letterSpacing: 0.5,
+ lineHeight: 24,
+ fontSize: 16,
+ },
+ bodyMedium: {
+ ...mediumType,
+ fontWeight: typeface.weightRegular,
+ fontFamily: typeface.brandRegular,
+ letterSpacing: 0.25,
+ lineHeight: 20,
+ fontSize: 14,
+ },
+ bodySmall: {
+ ...mediumType,
+ fontWeight: typeface.weightRegular,
+ fontFamily: typeface.brandRegular,
+ letterSpacing: 0.4,
+ lineHeight: 16,
+ fontSize: 12,
+ },
+
+ displayLargeEmphasized: {
+ ...emphasizedMediumType,
+ letterSpacing: -0.25,
+ lineHeight: 64,
+ fontSize: 57,
+ },
+ displayMediumEmphasized: {
+ ...emphasizedMediumType,
+ lineHeight: 52,
+ fontSize: 45,
+ },
+ displaySmallEmphasized: {
+ ...emphasizedMediumType,
+ lineHeight: 44,
+ fontSize: 36,
+ },
+
+ headlineLargeEmphasized: {
+ ...emphasizedMediumType,
+ lineHeight: 40,
+ fontSize: 32,
+ },
+ headlineMediumEmphasized: {
+ ...emphasizedMediumType,
+ lineHeight: 36,
+ fontSize: 28,
+ },
+ headlineSmallEmphasized: {
+ ...emphasizedMediumType,
+ lineHeight: 32,
+ fontSize: 24,
+ },
+
+ titleLargeEmphasized: {
+ ...emphasizedMediumType,
+ lineHeight: 28,
+ fontSize: 22,
+ },
+ titleMediumEmphasized: {
+ ...emphasizedBoldType,
+ letterSpacing: 0.15,
+ lineHeight: 24,
+ fontSize: 16,
+ },
+ titleSmallEmphasized: {
+ ...emphasizedBoldType,
+ letterSpacing: 0.1,
+ lineHeight: 20,
+ fontSize: 14,
+ },
+
+ labelLargeEmphasized: {
+ ...emphasizedBoldType,
+ letterSpacing: 0.1,
+ lineHeight: 20,
+ fontSize: 14,
+ },
+ labelMediumEmphasized: {
+ ...emphasizedBoldType,
+ letterSpacing: 0.5,
+ lineHeight: 16,
+ fontSize: 12,
+ },
+ labelSmallEmphasized: {
+ ...emphasizedBoldType,
+ letterSpacing: 0.5,
+ lineHeight: 16,
+ fontSize: 11,
+ },
+
+ bodyLargeEmphasized: {
+ ...emphasizedMediumType,
+ letterSpacing: 0.5,
+ lineHeight: 24,
+ fontSize: 16,
+ },
+ bodyMediumEmphasized: {
+ ...emphasizedMediumType,
+ letterSpacing: 0.25,
+ lineHeight: 20,
+ fontSize: 14,
+ },
+ bodySmallEmphasized: {
+ ...emphasizedMediumType,
+ letterSpacing: 0.4,
+ lineHeight: 16,
+ fontSize: 12,
+ },
+
+ default: {
+ ...regularType,
+ },
+};
export const defaultFonts: Typescale = typescale;
diff --git a/src/theme/types/color.ts b/src/theme/types/color.ts
index 1e49724b3e..84879a36ec 100644
--- a/src/theme/types/color.ts
+++ b/src/theme/types/color.ts
@@ -2,6 +2,10 @@ import type { ColorValue } from 'react-native';
import type { ElevationColors } from './elevation';
+export type ColorRole = {
+ [K in keyof ThemeColors]: ThemeColors[K] extends ColorValue ? K : never;
+}[keyof ThemeColors];
+
export type ThemeColors = {
primary: ColorValue;
primaryContainer: ColorValue;
diff --git a/src/theme/types/index.ts b/src/theme/types/index.ts
index cc1ffe21ef..b45fea45d5 100644
--- a/src/theme/types/index.ts
+++ b/src/theme/types/index.ts
@@ -3,6 +3,6 @@ export * from './elevation';
export * from './motion';
export * from './navigation';
export * from './shape';
+export * from './state';
export * from './theme';
export * from './typography';
-export * from './utils';
diff --git a/src/theme/types/motion.ts b/src/theme/types/motion.ts
index a0e037f524..f744e93d08 100644
--- a/src/theme/types/motion.ts
+++ b/src/theme/types/motion.ts
@@ -45,3 +45,9 @@ export type MotionConfig = {
easing: MotionEasing;
duration: MotionDuration;
};
+
+export type RawSpring = {
+ stiffness: number;
+ damping: number;
+ mass: number;
+};
diff --git a/src/theme/types/state.ts b/src/theme/types/state.ts
new file mode 100644
index 0000000000..7577d21ce4
--- /dev/null
+++ b/src/theme/types/state.ts
@@ -0,0 +1,10 @@
+import type { ColorValue } from 'react-native';
+
+import type { tokens } from '../tokens';
+
+export type StateOpacityKey = keyof typeof tokens.md.sys.state.opacity;
+
+export type StateLayer = {
+ color: ColorValue;
+ opacity: number;
+};
diff --git a/src/theme/types/utils.ts b/src/theme/types/utils.ts
deleted file mode 100644
index 0ea6b262b9..0000000000
--- a/src/theme/types/utils.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import type * as React from 'react';
-
-export type $Omit = Pick>;
-export type $RemoveChildren> = $Omit<
- React.ComponentPropsWithoutRef,
- 'children'
->;
-
-export type EllipsizeProp = 'head' | 'middle' | 'tail' | 'clip';
diff --git a/src/theme/utils/state.ts b/src/theme/utils/state.ts
new file mode 100644
index 0000000000..8addc54876
--- /dev/null
+++ b/src/theme/utils/state.ts
@@ -0,0 +1,23 @@
+import { tokens } from '../tokens';
+import type { ColorRole, StateLayer, StateOpacityKey, Theme } from '../types';
+
+const stateOpacity = tokens.md.sys.state.opacity;
+
+/**
+ * Resolve a `{ color, opacity }` state-layer for a color role + interaction
+ * state. Reads `theme.colors[role]` live, so it stays correct under deeply-merged theme overrides.
+ *
+ * @example
+ * const stateLayer = getStateLayer(theme, 'primary', 'hovered');
+ * // { color: theme.colors.primary, opacity: 0.08 }
+ */
+export function getStateLayer(
+ theme: Theme,
+ role: ColorRole,
+ state: StateOpacityKey
+): StateLayer {
+ return {
+ color: theme.colors[role],
+ opacity: stateOpacity[state],
+ };
+}
diff --git a/src/types.tsx b/src/types.tsx
index 261b55f0b3..c84e3915b4 100644
--- a/src/types.tsx
+++ b/src/types.tsx
@@ -1 +1,11 @@
+import type * as React from 'react';
+
export * from './theme/types';
+
+export type $Omit = Pick>;
+export type $RemoveChildren> = $Omit<
+ React.ComponentPropsWithoutRef,
+ 'children'
+>;
+
+export type EllipsizeProp = 'head' | 'middle' | 'tail' | 'clip';
diff --git a/src/utils/useFocusVisible.ts b/src/utils/useFocusVisible.ts
new file mode 100644
index 0000000000..c8798d64be
--- /dev/null
+++ b/src/utils/useFocusVisible.ts
@@ -0,0 +1,37 @@
+import * as React from 'react';
+import { Platform } from 'react-native';
+
+const isWeb = Platform.OS === 'web';
+
+type FocusVisibleEvent = {
+ currentTarget: object;
+};
+
+/**
+ * Convenience hook for components that gate a focus indicator on
+ * `:focus-visible` semantics. Returns `{ focusVisible, onFocus, onBlur }`;
+ * wire `onFocus` / `onBlur` to a `Pressable` (or equivalent) and gate the
+ * indicator on `focusVisible`.
+ *
+ * On web, delegates to the browser's `:focus-visible` matcher. On native,
+ * `onFocus` only fires for keyboard-style focus (external keyboard, D-pad,
+ * a11y navigation, programmatic focus), so `focusVisible` is always `true`
+ * while focused.
+ */
+export function useFocusVisible() {
+ const [focusVisible, setFocusVisible] = React.useState(false);
+ const onFocus = React.useCallback((e: FocusVisibleEvent) => {
+ if (!isWeb) {
+ setFocusVisible(true);
+ return;
+ }
+ const target = e.currentTarget;
+ const matches =
+ 'matches' in target && typeof target.matches === 'function'
+ ? target.matches
+ : null;
+ setFocusVisible(!!matches?.call(target, ':focus-visible'));
+ }, []);
+ const onBlur = React.useCallback(() => setFocusVisible(false), []);
+ return { focusVisible, onFocus, onBlur };
+}