If Figma and Tailwind can both change the same colour, neither one is really the source of truth. They are two sources of suggestions. A reliable pipeline starts by choosing one authority and making every platform consume it deliberately.

In this example, a repository-owned token contract feeds Figma variables, Tailwind CSS 4.3.3 theme variables for web and a generated NativeWind theme for React Native with Expo. Each platform consumes the same semantic vocabulary while keeping the representation it needs.

That last part is important. Sharing design decisions does not mean making Figma understand CSS or making an Expo application import the web application’s Tailwind v4 output. It means deciding once that a primary Button uses action/primary/background, then translating that decision into a Figma alias and stable utility names for Tailwind and NativeWind.

This is the setup I would start with today. The examples are neutral recreations rather than code or values from an employer project.

1. Where should design tokens live?

For this setup, the authority lives in the repository. Repository-owned does not have to mean that every JSON file is written by hand. A generator may produce the reviewed JSON from another repository-owned format. What matters is that there is one approved change path, the result is reviewable and every consumer can be regenerated from it.

Generated JSON is not automatically the authority just because it sits in a repository. It may be the reviewed input to the platform pipeline, or it may be an output of another source file. I inspect the current JSON and identify what produces it before deciding where a change belongs.

I also do not choose a transformer by habit. The example below uses a small, sanitised DTCG 2025.10 fixture and defines the contract a generator must satisfy. It does not pretend the unnamed implementation is Style Dictionary, a custom script or anything else.

reviewed token contract
        │
        ├── Figma light and dark mode JSON
        ├── web CSS custom properties and Tailwind theme variables
        └── NativeWind preset and light/dark variables for Expo
                    │
                    └── web and native Buttons use the same semantic roles

I prefer this direction because token changes then behave like code changes. They can be reviewed, validated and reproduced in CI. Figma remains a first-class consumer and an important place to test the design, but it is not a second authority.

A Figma-owned workflow can also work. In that model, design owns the lifecycle and a reviewed Figma export enters the repository. The same rule still applies: name the authority, name the review boundary and do not quietly edit both sides.

2. What is a token, and what is only a reusable asset?

I separate the system into three levels.

  • Primitive tokens are raw choices such as an orange, a spacing value or a font size. colour/orange/700 is a primitive.

  • Semantic tokens describe why a value is used. action/primary/background says considerably more than orange/700, even when it currently points to that orange.

  • Component decisions combine semantic tokens into a usable interface. A Button chooses an action background, text colour, focus ring, padding, radius and type recipe.

An icon is not a token. The icon’s foreground colour and standard size can be tokens, but the SVG path is an asset and its React wrapper is a component. Treating all reusable design material as tokens turns a small vocabulary into a storage cupboard.

The distinction also protects the shared UI library. A primitive Button can own presentation, accessibility and supported visual states. It should not know how checkout works, whether a survey can be submitted or what an account balance means. Those decisions belong to product features.

This separation is one of the foundations behind the UI Kit work I describe in one repo, three platforms, and an AI that needed house rules. It also builds on the earlier lesson of making shared components visible in the Appliances Online platform work.

3. Why do semantic names survive better than palette names?

Consider a primary Button in light and dark themes:

Button decision Light mode Dark mode
action/primary/background colour/orange/700 colour/orange/400
action/primary/text colour/neutral/0 colour/neutral/950
action/disabled/background colour/neutral/200 colour/neutral/700
action/disabled/text colour/neutral/700 colour/neutral/200
focus/ring colour/orange/700 colour/orange/400
surface/default colour/neutral/50 colour/neutral/950
text/default colour/neutral/950 colour/neutral/50

The component asks for an action background. It does not ask for orange 700 and then contain a dark-mode exception. This lets a theme change the relationship without changing the Button API.

Palette names are still useful at the primitive layer. They help designers understand available colour steps and let a semantic alias point somewhere precise. They become a problem when application code depends on them directly. A class such as bg-orange-700 dark:bg-orange-400 has already moved a theme decision into the component.

I want the Button to say bg-action-primary. The mapping behind that class can change without asking every consumer to understand why.

4. What does a DTCG token file look like?

The Design Tokens Format Module 2025.10 gives tools a shared JSON shape for token types, values and references. It is a W3C Community Group Final Report, not a W3C Standard or Recommendation. That difference matters when describing how portable the format is.

This extract shows the relationships without turning the article into a token catalogue. It stays inside Figma’s current native import subset: an sRGB colour, a pixel dimension and a single font-family string.

Listing 01An extract from figma/light.tokens.jsonJSON
{
	"colour": {
		"orange": {
			"700": {
				"$type": "color",
				"$value": {
					"colorSpace": "srgb",
					"components": [0.7608, 0.2549, 0.0471],
					"alpha": 1,
					"hex": "#c2410c"
				}
			}
		},
		"neutral": {
			"0": {
				"$type": "color",
				"$value": {
					"colorSpace": "srgb",
					"components": [1, 1, 1],
					"alpha": 1,
					"hex": "#ffffff"
				}
			}
		}
	},
	"action": {
		"primary": {
			"background": {
				"$type": "color",
				"$value": "{colour.orange.700}"
			},
			"text": {
				"$type": "color",
				"$value": "{colour.neutral.0}"
			}
		}
	},
	"spacing": {
		"button-x": {
			"$type": "dimension",
			"$value": { "value": 16, "unit": "px" }
		}
	},
	"type": {
		"button": {
			"family": { "$type": "fontFamily", "$value": "Inter" }
		}
	}
}

Curly braces create an alias. {colour.orange.700} points to the token’s value while preserving the relationship, so consumers can follow a primitive change without copying its value into every semantic token. A validator must also catch missing references, type mismatches and circular references before any platform output is built.

The complete fixture also contains the surface, default text, focus, disabled, remaining spacing, radius and typography tokens used later in the Button. Keeping the article extract short does not change the import rule: each complete mode file needs the same token paths and types.

The dark file must contain the same token names and the same $type for every token. The primitives and non-colour values stay identical. Only these semantic aliases change:

Listing 02The semantic colour group in figma/dark.tokens.jsonJSON
{
	"surface": {
		"default": {
			"$type": "color",
			"$value": "{colour.neutral.950}"
		}
	},
	"text": {
		"default": {
			"$type": "color",
			"$value": "{colour.neutral.50}"
		}
	},
	"action": {
		"primary": {
			"background": {
				"$type": "color",
				"$value": "{colour.orange.400}"
			},
			"text": {
				"$type": "color",
				"$value": "{colour.neutral.950}"
			}
		},
		"disabled": {
			"background": {
				"$type": "color",
				"$value": "{colour.neutral.700}"
			},
			"text": {
				"$type": "color",
				"$value": "{colour.neutral.200}"
			}
		}
	},
	"focus": {
		"ring": {
			"$type": "color",
			"$value": "{colour.orange.400}"
		}
	}
}

That second block is a readable extract, not a complete import file. The real dark file still includes every token from the light file. If type/button/size exists in light and not in dark, Figma will not create it when both files are imported into a new collection.

5. How do the tokens become Figma variables and modes?

Figma can import DTCG JSON directly into a variable collection. For this fixture, I would use one collection first. Splitting primitives and semantic tokens across collections makes the diagram look tidy, but cross-collection aliases need Figma’s com.figma.aliasData extension and collection identifiers. That is extra vendor-specific machinery before the basic pipeline has proved itself.

The setup is:

  1. Generate complete light.tokens.json and dark.tokens.json files with identical token paths and types.
  2. Open the Variables view and create a new collection.
  3. Drag both JSON files into that collection together. Figma creates one mode per file.
  4. Rename the modes to Light and Dark if the filenames did not produce the names you want.
  5. Check that semantic variables are aliases rather than copied colour values.
  6. Set Light as the default mode, then switch a test frame and the Button component between both modes.
  7. Apply sensible scopes in Figma so colour, spacing and radius variables appear where designers expect them.

There are some constraints worth knowing before a large token set is involved. As documented on 31 August 2026, Figma’s import table accepts HSL and sRGB colours, pixel dimensions, one font-family string, durations in seconds, numbers, a Figma boolean extension and strings. It does not import every type defined by DTCG.

Figma also normalises nested names with /. The JSON path action.primary.background appears as action/primary/background. Two source paths that collapse to the same normalised name are a collision, and Figma keeps only the first one it encounters. Make that collision a validation error rather than waiting for an import to reveal it.

There is another trap around export. Figma can export one mode or all modes as JSON, but its documentation does not promise a lossless round trip for the whole DTCG format. After the first import, export the fixture and diff it against the generated input. Check names, types, aliases, extension data and values. An export button is not evidence that two formats are identical.

6. How should typography cross the boundary?

Typography is where a claim of automatic synchronisation usually falls apart. A text style is a recipe: family, size, weight, line height, letter spacing and sometimes more. Figma’s current DTCG import table does not list the composite typography type.

Import the compatible ingredients instead. In the example, family is a fontFamily, size and line height are pixel dimensions, and weight is a number. In Figma, bind those variables into a Button label text style. In code, the generator maps the same ingredients to the platform’s typography representation.

The web output appends sans-serif to the Inter family while Figma imports the single family name it supports. That fallback is a documented web transformation, not another token value. The native application has a different responsibility again: it must load the font before the generated family name can work.

This means the text style itself still needs deliberate ownership in Figma. The token pipeline supplies its ingredients and can test their values, but it should not claim that a composite style travelled to Figma and back without loss.

The same caution applies to effects and icons. If a platform cannot represent a token type directly, generate a platform-specific recipe or keep that asset in the correct component library. Do not flatten everything to strings just to make a JSON import succeed.

7. How does Tailwind CSS v4 consume the same decisions?

Tailwind CSS v4 exposes design decisions through theme-variable namespaces. A variable named --color-action-primary creates colour utilities such as bg-action-primary. --spacing-button-x creates spacing utilities, and the font, text, weight, leading and radius namespaces do the same for their parts of the API.

The mode values should remain ordinary CSS custom properties. A single top-level @theme inline block then maps those runtime values into Tailwind’s stable utility namespaces.

Listing 03generated/tailwind-theme.cssCSS
@import "tailwindcss";

:root {
	color-scheme: light;

	--ds-colour-surface-default: #fafaf9;
	--ds-colour-text-default: #1c1917;
	--ds-colour-action-primary-background: #c2410c;
	--ds-colour-action-primary-text: #ffffff;
	--ds-colour-action-disabled-background: #e7e5e4;
	--ds-colour-action-disabled-text: #44403c;
	--ds-colour-focus-ring: #c2410c;

	--ds-spacing-button-x: 1rem;
	--ds-spacing-button-y: 0.625rem;
	--ds-radius-button: 0.5rem;
	--ds-font-family-button: "Inter", sans-serif;
	--ds-font-size-button: 0.875rem;
	--ds-font-weight-button: 600;
	--ds-line-height-button: 1.25rem;
}

[data-theme="dark"] {
	color-scheme: dark;

	--ds-colour-surface-default: #1c1917;
	--ds-colour-text-default: #fafaf9;
	--ds-colour-action-primary-background: #fb923c;
	--ds-colour-action-primary-text: #1c1917;
	--ds-colour-action-disabled-background: #44403c;
	--ds-colour-action-disabled-text: #e7e5e4;
	--ds-colour-focus-ring: #fb923c;
}

@theme inline {
	--color-surface: var(--ds-colour-surface-default);
	--color-content: var(--ds-colour-text-default);
	--color-action-primary: var(--ds-colour-action-primary-background);
	--color-on-action-primary: var(--ds-colour-action-primary-text);
	--color-action-disabled: var(--ds-colour-action-disabled-background);
	--color-on-action-disabled: var(--ds-colour-action-disabled-text);
	--color-focus-ring: var(--ds-colour-focus-ring);

	--spacing-button-x: var(--ds-spacing-button-x);
	--spacing-button-y: var(--ds-spacing-button-y);
	--radius-button: var(--ds-radius-button);
	--font-button-family: var(--ds-font-family-button);
	--text-button: var(--ds-font-size-button);
	--font-weight-button-label: var(--ds-font-weight-button);
	--leading-button: var(--ds-line-height-button);
}

The inline option is doing real work here. Tailwind documents it for theme variables that reference other variables. It causes the generated utility to use the referenced value, such as var(--ds-colour-action-primary-background), instead of relying on where the Tailwind theme variable itself was resolved.

Do not put @theme inside [data-theme="dark"]. The utility namespace is stable. The ordinary semantic properties switch under the selector, and the generated utility follows them.

The Button can now stay pleasantly boring:

Listing 04libs/shared/ui/src/Button/Button.tsxTSX
import type { ButtonHTMLAttributes } from "react";

export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement>;

const buttonClassName =
	"rounded-button bg-action-primary px-button-x py-button-y font-button-family text-button font-button-label leading-button text-on-action-primary outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface disabled:cursor-not-allowed disabled:bg-action-disabled disabled:text-on-action-disabled";

export function Button({ className, type = "button", ...props }: ButtonProps) {
	return (
		<button
			type={type}
			className={[buttonClassName, className].filter(Boolean).join(" ")}
			{...props}
		/>
	);
}

The longer family and weight names are intentional. Both Tailwind namespaces produce utilities beginning with font-, so calling each token only button creates a collision. Token names should be clear in the consumer, not merely valid in the generator.

The disabled state also has explicit background and text roles. Applying opacity to the whole component can lower contrast in ways the component cannot see. It is still necessary to test the chosen disabled pair, but at least that decision now has somewhere to live.

8. Where should source tokens and generated files live?

The exact folders depend on the workspace, but I want the ownership visible at a glance:

libs/shared/design-tokens/
  tokens/                 # reviewed authority for the tutorial fixture
  build/                  # the validated, pinned transformation step
  generated/
    figma/
      light.tokens.json
      dark.tokens.json
    web/
      tailwind-theme.css
    native/
      nativewind.preset.cjs
      nativewind-themes.ts

libs/shared/ui/src/Button/
  Button.tsx
  Button.stories.tsx
  Button.figma.ts

Generated files should carry a do-not-edit header and be deterministic. If the same input produces a different output without a version change, the pipeline is not safe enough to put between design and several applications.

This article defines the output contract rather than pretending to document an unverified transformer. Before choosing the implementation of build/, inspect the repository’s actual JSON, identify what produces it and test that tool against the types the system uses. Installing a popular token package first and bending the contract around it is backwards.

The transform still needs a written contract. For this example it is:

  • Figma receives two complete DTCG files with matching paths and types.
  • Web receives ordinary semantic properties plus one top-level Tailwind bridge.
  • NativeWind receives a generated Tailwind preset plus light and dark variable values that resolve to native styles.
  • Every platform output preserves the same semantic token names in a documented naming convention.

That contract is testable regardless of whether the implementation becomes a small script or a dedicated token tool.

9. How does the same contract support Expo through NativeWind?

The Expo application in this setup uses NativeWind. It does not import the web application’s Tailwind v4 stylesheet. NativeWind has its own CSS entry and Metro build step, which compile supported Tailwind utilities into React Native styles.

There is a version boundary here that is easy to miss. As checked on 31 August 2026, NativeWind 4.2.6 is the current stable package and its official Expo setup uses Tailwind CSS ^3.4.17. NativeWind v5 is the path to Tailwind CSS v4, but the project currently labels v5 as a preview that is not intended for production use. I would keep the web on Tailwind v4 and generate a separate NativeWind v4 artefact until that changes.

The shared part is the token contract, not the Tailwind engine version. The generator resolves the same DTCG aliases and writes a NativeWind preset:

Listing 05generated/native/nativewind.preset.cjsJavaScript
module.exports = {
	theme: {
		extend: {
			colors: {
				surface: "rgb(var(--ds-colour-surface) / <alpha-value>)",
				content: "rgb(var(--ds-colour-content) / <alpha-value>)",
				"action-primary":
					"rgb(var(--ds-colour-action-primary) / <alpha-value>)",
				"on-action-primary":
					"rgb(var(--ds-colour-on-action-primary) / <alpha-value>)",
				"action-disabled":
					"rgb(var(--ds-colour-action-disabled) / <alpha-value>)",
				"on-action-disabled":
					"rgb(var(--ds-colour-on-action-disabled) / <alpha-value>)",
				"focus-ring": "rgb(var(--ds-colour-focus-ring) / <alpha-value>)",
			},
			spacing: {
				"button-x": "16px",
				"button-y": "10px",
			},
			borderRadius: {
				button: "8px",
			},
			fontFamily: {
				"button-family": ["Inter"],
			},
			fontSize: {
				button: "14px",
			},
			fontWeight: {
				"button-label": "600",
			},
			lineHeight: {
				button: "20px",
			},
		},
	},
};

The Expo application’s own Tailwind config includes both the NativeWind preset and this generated one. Its content paths must also include the shared UI library, otherwise those classes will not be generated:

module.exports = {
	content: [
		"./App.tsx",
		"./app/**/*.{ts,tsx}",
		"./src/**/*.{ts,tsx}",
		"./libs/shared/ui/src/**/*.{ts,tsx}",
	],
	presets: [
		require("nativewind/preset"),
		require("./libs/shared/design-tokens/generated/native/nativewind.preset.cjs"),
	],
};

This sits beside NativeWind’s standard v4 setup: a global.css file with the three @tailwind directives, withNativeWind() pointing Metro at that file and one import at the application entry. The generated preset extends that setup. It does not replace it.

The px values in that preset are deliberate. React Native lays out in density-independent pixels, while the web uses CSS pixels. NativeWind’s documented rule for custom theme values is to write 10px, for example, and let it translate the value for native. That keeps the generated file explicit and avoids relying on the different native and web rem bases.

Colours need runtime values for each mode. The generator writes RGB channels because the preset wraps them with rgb() and keeps Tailwind’s opacity syntax available:

Listing 06generated/native/nativewind-themes.tsTypeScript
import { vars } from "nativewind";

export const nativeThemes = {
	light: vars({
		"--ds-colour-surface": "250 250 249",
		"--ds-colour-content": "28 25 23",
		"--ds-colour-action-primary": "194 65 12",
		"--ds-colour-on-action-primary": "255 255 255",
		"--ds-colour-action-disabled": "231 229 228",
		"--ds-colour-on-action-disabled": "68 64 60",
		"--ds-colour-focus-ring": "194 65 12",
	}),
	dark: vars({
		"--ds-colour-surface": "28 25 23",
		"--ds-colour-content": "250 250 249",
		"--ds-colour-action-primary": "251 146 60",
		"--ds-colour-on-action-primary": "28 25 23",
		"--ds-colour-action-disabled": "68 64 60",
		"--ds-colour-on-action-disabled": "231 229 228",
		"--ds-colour-focus-ring": "251 146 60",
	}),
} as const;

These files are generated together. Nobody should maintain the RGB channels, preset values or Figma mode files separately.

Expo can follow the device preference when userInterfaceStyle is automatic:

{
	"expo": {
		"userInterfaceStyle": "automatic"
	}
}

Expo’s current documentation notes that Android development builds need expo-system-ui for this setting. I would verify that dependency in the native application rather than hide it inside the token package.

At the application root, NativeWind’s v4 useColorScheme() selects the generated variable set. The variables inherit through the component tree:

Listing 07Applying the NativeWind theme at the Expo rootTSX
import type { PropsWithChildren } from "react";
import { useColorScheme } from "nativewind";
import { View } from "react-native";

import { nativeThemes } from "../design-tokens/generated/native/nativewind-themes";

export function NativeThemeProvider({ children }: PropsWithChildren) {
	const { colorScheme } = useColorScheme();
	const theme = colorScheme === "dark" ? nativeThemes.dark : nativeThemes.light;

	return (
		<View className="flex-1" style={theme}>
			<View className="flex-1 bg-surface">{children}</View>
		</View>
	);
}

The Button then consumes the same semantic class names as the web component:

Listing 08A NativeWind Button using semantic utilitiesTSX
import type { PropsWithChildren } from "react";
import { Pressable, Text } from "react-native";

type ButtonProps = PropsWithChildren<{
	disabled?: boolean;
	onPress: () => void;
}>;

const enabledRootClassName =
	"items-center rounded-button bg-action-primary px-button-x py-button-y active:opacity-90";
const disabledRootClassName =
	"items-center rounded-button bg-action-disabled px-button-x py-button-y";
const enabledLabelClassName =
	"font-button-family text-button font-button-label leading-button text-on-action-primary";
const disabledLabelClassName =
	"font-button-family text-button font-button-label leading-button text-on-action-disabled";

export function Button({ children, disabled = false, onPress }: ButtonProps) {
	return (
		<Pressable
			accessibilityRole="button"
			accessibilityState={{ disabled }}
			className={disabled ? disabledRootClassName : enabledRootClassName}
			disabled={disabled}
			onPress={onPress}
		>
			<Text
				className={disabled ? disabledLabelClassName : enabledLabelClassName}
			>
				{children}
			</Text>
		</Pressable>
	);
}

Keeping complete class strings matters because NativeWind needs to find them while scanning source files. A runtime expression such as `bg-${colour}` hides the class from that build step.

Some native APIs take a colour or number prop rather than a className. If the design system needs one of those values at runtime, I would generate a sibling typed export from the same contract. I would not make a component parse the CSS file or reach back into primitive JSON.

This is intentionally the platform boundary and no more. A complete native Button still needs tested pressed, disabled and focus behaviour, platform-specific font loading and accessibility verification. NativeWind also cannot make unsupported browser CSS behave like a native layout API. The semantic role is shared, but each platform still owns its implementation.

10. How does one Button prove the contract?

The Button is useful because it forces several token categories to meet. A colour-only demo can hide weak naming. A complete primitive needs typography, spacing, radius, interaction states and two themes.

I would verify the component in three places:

  1. In Figma, switch the containing frame between Light and Dark. The Button should follow aliases, with no manually overridden fill or text colour.
  2. In Storybook, render default, focus-visible and disabled states in both themes. Inspect the computed styles and confirm the semantic custom properties changed while the class names did not.
  3. In Expo, switch the simulator or device appearance and confirm NativeWind resolves the same stable class names against the light and dark variables.

The comparison should be about decisions rather than pixel screenshots. The horizontal padding should be the same token, the label should use the same type recipe and the primary action should retain the intended contrast in both themes. Web and native rendering will still differ because they are different platforms.

Storybook matters here because it makes the implemented states available to designers and engineers away from a complete product flow. That was a useful part of the shared UI work I describe in bringing the shared UI kit to life. It is a verification surface, not another token authority.

11. How do teams stop the consumers drifting again?

Automation should make the wrong path inconvenient. A token pull request ought to fail before review if an alias is missing, mode shapes differ or generated files are stale.

The exact commands will depend on the generator, but the pipeline needs these checks:

  1. Parse every source file and validate its DTCG types.
  2. Resolve all aliases, rejecting missing targets, type mismatches and cycles.
  3. Normalise names as Figma will and reject collisions.
  4. Compare light and dark token paths and types.
  5. Generate Figma, web and native outputs twice and confirm the second run changes nothing.
  6. Fail CI when regeneration leaves an uncommitted diff.
  7. Type-check the generated TypeScript, load the generated NativeWind preset and build both the web Tailwind and Expo fixtures.
  8. Test each semantic foreground and background pair for contrast in every mode.
  9. Render the Button states in Storybook and the bounded Expo example.

A CI step can be as simple as this at the boundary, once the project has named its real scripts:

npm run tokens:validate
npm run tokens:build
git diff --exit-code -- libs/shared/design-tokens/generated

That command does not prove the design is good. It proves the checked-in output matches the reviewed input. Design review, contrast testing and component review still own the result.

Figma needs a corresponding operating rule. When a token change is approved, regenerate the mode files, import them into the library, review both modes and publish the variable update. If somebody edits a Figma variable directly during exploration, either bring that decision back through the repository or discard it. Leaving it as an undocumented third state is how drift returns.

12. What do design tokens not solve?

Tokens align values and roles. They do not align a Figma component property named Size=Small with a React prop named size="sm". They do not tell an engineer which shared component to import, and they do not stop an AI coding agent from recreating a Button that already exists.

That is the next boundary. Figma Code Connect with React and MCP starts with this already-tokenised Button and connects the design component API to the real React API. Keeping the two concerns separate makes both systems easier to reason about: tokens answer which design decision, while Code Connect answers which implementation and which props.

The useful outcome is not that Figma and code contain matching orange values today. It is that there is one named route for changing the decision tomorrow, and every consumer can show whether it still agrees.

Sources and further reading