Skip to content

[pull] main from tldraw:main#477

Merged
pull[bot] merged 2 commits intocode:mainfrom
tldraw:main
Apr 2, 2026
Merged

[pull] main from tldraw:main#477
pull[bot] merged 2 commits intocode:mainfrom
tldraw:main

Conversation

@pull
Copy link
Copy Markdown

@pull pull Bot commented Apr 2, 2026

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

steveruizok and others added 2 commits April 2, 2026 16:16
…stomizing default shapes (#8410)

## Summary

This PR introduces a first-class theme system, replacing the previous
approach where colors were hardcoded and resolved inline. Themes are now
named, registered objects that shape utils consume via a structured
display values pipeline.


https://github.com/user-attachments/assets/a536084f-b999-41e7-981b-a365b1f5b6c3

### Concepts

| Term | Type | Meaning |
|------|------|---------|
| **Theme** | `TLTheme` | A named set of colors and typographic values
(`fontSize`, `lineHeight`, `strokeWidth`) for both light and dark modes.
Register custom themes via `TLThemes` module augmentation for type-safe
IDs. |
| **Theme ID** | `TLThemeId` | A type-safe key into `TLThemes` (e.g.
`'default'`, `'corporate'`). Derived as `keyof TLThemes & string`. |
| **Theme default colors** | `TLThemeDefaultColors` | The full color
interface for one mode. Augmentable to add custom named colors. |
| **Theme colors** | `TLThemeColors` | The resolved palette type used by
`TLTheme`. Equals `TLThemeDefaultColors` minus any keys in
`TLRemovedDefaultThemeColors`. |
| **Color mode** | `'light' \| 'dark'` | The resolved appearance,
derived from the user's color scheme preference. |
| **Color scheme** | `'light' \| 'dark' \| 'system'` | The user's
preference. `'system'` resolves to a color mode based on OS settings. |
| **Display values** | `getDefaultDisplayValues` /
`getCustomDisplayValues` | Shape util callbacks that produce cached,
computed visual properties (colors, stroke widths, font sizes) from the
theme and color mode. |
| **ThemeManager** | `ThemeManager` | Editor manager that stores themes,
tracks the active theme, and exposes the resolved color mode. |

### Module augmentation

```ts
// Register a new theme ID
declare module '@tldraw/tlschema' {
  interface TLThemes {
    corporate: TLTheme
  }
}

// Register a new color name
declare module '@tldraw/tlschema' {
  interface TLThemeDefaultColors {
    pink: TLDefaultColor
  }
}

// Remove built-in colors from the palette
declare module '@tldraw/tlschema' {
  interface TLRemovedDefaultThemeColors {
    'light-violet': true
    'light-blue': true
  }
}
```

### Removing colors

To remove built-in palette colors, augment
`TLRemovedDefaultThemeColors`. Each key you add is omitted from
`TLThemeColors`, so TypeScript no longer expects it in theme definitions
and the color won't appear in the style panel. UI infrastructure colors
(`text`, `background`, `negativeSpace`, `solid`, `cursor`, `noteBorder`)
cannot be removed.

### Editor API

| Method | Description |
| --- | --- |
| `getCurrentTheme()` | Get the current theme |
| `getCurrentThemeId()` | Get the id of the current theme |
| `setCurrentTheme(id)` | Set the current theme by id |
| `getThemes()` | Get all registered themes |
| `getTheme(id)` | Get a theme by id |
| `updateTheme(id, partial)` | Update a named theme (callback receives a
`structuredClone` deep copy, so you can mutate directly) |
| `updateThemes(themesOrFn)` | Update or remove themes (callback
receives a deep copy; must keep `'default'`) |
| `getColorMode()` | Get the resolved color mode (`'light'` or `'dark'`)
|
| `setColorMode(mode)` | Set the color mode (`'light'` or `'dark'`) |

### Component props

| Prop | Type | Description |
| --- | --- | --- |
| `themes` | `Partial<TLThemes>` | Named themes for the editor |
| `initialTheme` | `TLThemeId` | The initially active theme (default:
`'default'`) |

### Display values for default shapes

Each default shape util defines `getDefaultDisplayValues` in its
`options`, which resolves the shape's visual properties from the current
theme and color mode. These are consumed via `getDisplayValues(util,
shape)` and cached automatically.

Here's what each default shape returns:

| Shape | Display values |
| --- | --- |
| **draw** | `strokeColor`, `strokeWidth`, `fillColor`,
`patternFillFallbackColor` |
| **geo** | `strokeColor`, `strokeWidth`, `strokeRoundness`,
`fillColor`, `patternFillFallbackColor`, `labelColor`,
`labelFontFamily`, `labelFontSize`, `labelMinWidth`,
`labelExtraPadding`, `labelLineHeight`, `labelFontWeight`,
`labelFontVariant`, `labelFontStyle`, `labelHorizontalAlign`,
`labelVerticalAlign`, `labelPadding`, `labelEdgeMargin`,
`minSizeWithLabel` |
| **arrow** | `strokeColor`, `strokeWidth`, `fillColor`,
`patternFillFallbackColor`, `labelColor`, `labelFontFamily`,
`labelFontSize`, `labelLineHeight`, `labelPadding`, `labelBorderRadius`
|
| **text** | `color`, `fontFamily`, `fontSize`, `lineHeight`,
`fontWeight`, `fontStyle`, `fontVariant` |
| **note** | `noteWidth`, `noteHeight`, `noteBackgroundColor`,
`borderColor`, `borderWidth`, `labelColor`, `labelFontFamily`,
`labelFontSize`, `labelLineHeight`, `labelFontWeight`,
`labelFontVariant`, `labelFontStyle`, `labelPadding`,
`labelHorizontalAlign`, `labelVerticalAlign` |
| **line** | `strokeColor`, `strokeWidth` |
| **highlight** | `strokeColor`, `strokeWidth`, `underlayOpacity`,
`overlayOpacity` |
| **frame** | `fillColor`, `strokeColor`, `showColorsFillColor`,
`showColorsStrokeColor`, `headingFillColor`, `headingStrokeColor`,
`headingTextColor`, `showColorsHeadingFillColor`,
`showColorsHeadingStrokeColor`, `showColorsHeadingTextColor` |
| **embed** | `showShadow` |
| **image**, **video**, **bookmark** | _(none)_ |

Example — how `DrawShapeUtil` defines and consumes display values:

```ts
// In DrawShapeUtil options:
getDefaultDisplayValues(_editor, shape, theme, colorMode) {
  const { color, fill, size } = shape.props
  const colors = theme.colors[colorMode]
  return {
    strokeColor: getColorValue(colors, color, 'solid'),
    strokeWidth: theme.strokeWidth * STROKE_SIZES[size],
    fillColor:
      fill === 'none'
        ? 'transparent'
        : fill === 'semi'
          ? colors.solid
          : getColorValue(colors, color, DEFAULT_FILL_COLOR_NAMES[fill]),
    patternFillFallbackColor: getColorValue(colors, color, 'semi'),
  }
}

// In DrawShapeUtil.component():
component(shape: TLDrawShape) {
  const dv = getDisplayValues(this, shape)
  return (
    <SVGContainer>
      <DrawShapeSvg
        shape={shape}
        strokeColor={dv.strokeColor}
        strokeWidth={dv.strokeWidth}
        fillColor={dv.fillColor}
        patternFillFallbackColor={dv.patternFillFallbackColor}
      />
    </SVGContainer>
  )
}
```

To override display values for a default shape, use
`getCustomDisplayValues`:

```ts
const MyDrawShapeUtil = DrawShapeUtil.configure({
  getCustomDisplayValues(_editor, _shape, _theme, _colorMode) {
    return { strokeWidth: 10 } // always use a thick stroke
  },
})
```

## FAQ

**How do I customize the default theme's colors?**
```ts
editor.updateTheme('default', (theme) => {
  // The callback receives a deep copy, so mutate directly
  theme.colors.light.black.solid = 'aqua'
  return theme
})
```

**How do I register and switch to a custom theme?**
```tsx
<Tldraw
  themes={{ corporate: myCorporateTheme }}
  initialTheme="corporate"
/>
```

**How do I add a custom color that shapes can use?**
Augment `TLThemeDefaultColors` and include the color in your theme's
palettes. See the [custom theme
example](apps/examples/src/examples/ui/custom-theme/).

**How do I remove built-in colors?**
Augment `TLRemovedDefaultThemeColors` to list the colors you want
removed. They'll be omitted from the palette type and won't appear in
the style panel. See the [custom theme
example](apps/examples/src/examples/ui/custom-theme/).

**How do I remove a theme at runtime?**
```ts
editor.updateThemes(({ myOldTheme, ...rest }) => rest)
```

**How do I change the font size?**
`theme.fontSize` is a base multiplier used by all default shape utils to
compute their final font sizes. For example, a text shape's rendered
size is `theme.fontSize * FONT_SIZES[size]`, where `FONT_SIZES` maps the
size style (`'s'`, `'m'`, `'l'`, `'xl'`) to a relative scale. The same
pattern applies to geo labels, note labels, arrow labels, and highlight
stroke widths. Changing `theme.fontSize` scales all of them
proportionally.
```ts
editor.updateTheme('default', (theme) => {
  theme.fontSize = 24
  return theme
})
```

**Do I need to use display values for my custom shapes?**
No. The display values system (`getDefaultDisplayValues` /
`getCustomDisplayValues` / `getDisplayValues`) is designed primarily for
tldraw's default shapes — it's the mechanism that lets them resolve
colors, font sizes, and stroke widths from the current theme and color
mode, and that lets downstream users override those values without
forking the shape util. If you're building custom shapes, you don't need
to opt into display values at all. You can read theme data directly via
`editor.getCurrentTheme()` and `editor.getColorMode()` if you want to,
or hardcode colors, or use your own styling system entirely.

**How do I read the current colors for a shape util?**
Use `getDisplayValues(util, shape)` — it returns cached display values
computed from the current theme and color mode. Override via
`getCustomDisplayValues` in shape options.

## New examples

- **Custom theme** (`ui/custom-theme`): Creating a fully custom theme
with custom colors, custom fonts, removed colors, and adjustable stroke
widths / font sizes.
- **Multiple themes** (`ui/multiple-themes`): `TLThemes` module
augmentation, registering multiple named themes, and switching between
them at runtime.
- **Display options** (`configuration/display-options`): Configuring
display values like stroke widths, font sizes, and line heights via
`getCustomDisplayValues`.
- **Dark mode** (`ui/dark-mode`): Using the new `colorScheme` prop
(renamed from `inferDarkMode`).

### Change type

- [x] `feature`

### Test plan

- [x] `yarn typecheck` passes
- [x] `displayValues.test.ts` — 16 tests pass (caching, theme changes,
dark mode, overrides)
- [ ] Manual: verify shapes render correctly in light and dark mode
- [ ] Manual: verify SVG export uses correct colors for both modes

### Release notes

- Add first-class theme system with named themes, type-safe theme IDs,
and display values for customizing default shapes
- Add `TLRemovedDefaultThemeColors` interface for type-safe removal of
built-in palette colors
- Add `negativeSpace` theme color for frame heading knockouts and text
outlines
- Add `updateTheme` / `updateThemes` methods with mutable deep-copy
callbacks
- Add custom font support via `TLThemeFonts` module augmentation

### API changes

**Added:**
- Added `TLTheme`, `TLThemeId`, `TLThemes`, `TLThemeDefaultColors`,
`TLThemeColors`, `TLThemeUiColorKeys`, `TLRemovedDefaultThemeColors`,
`TLDefaultColor`, `TLThemeFont`, `TLThemeFonts` types
- Added `ThemeManager` class with `getCurrentTheme()`,
`getCurrentThemeId()`, `setCurrentTheme()`, `getThemes()`, `getTheme()`,
`updateTheme()`, `updateThemes()`, `getColorMode()`
- Added `getDisplayValues()`, `getColorValue()`, `DEFAULT_THEME`,
`DEFAULT_THEME_FONTS`
- Added `themes` and `initialTheme` props to `<Tldraw>` and
`<TldrawEditor>`

**Breaking — removed from `@tldraw/tlschema`:**
- `defaultColorNames` — removed (colors are now defined by
`TLThemeDefaultColors`)
- `DefaultColorThemePalette` — removed (use
`editor.getCurrentTheme().colors` instead)
- `DefaultLabelColorStyle` — removed (use `DefaultColorStyle` instead)
- `TLDefaultColorTheme` type — removed (replaced by `TLThemeColors`)
- `TLDefaultColorThemeColor` interface — renamed to `TLDefaultColor`
- `getDefaultColorTheme()` — removed (use
`editor.getCurrentTheme().colors[colorMode]` instead)
- `getColorValue(theme, color, variant)` → `getColorValue(colors, color,
variant)` (first arg is now `TLThemeColors` instead of
`TLDefaultColorTheme`)
- `registerColorsFromThemeDefinitions` → `registerColorsFromThemes`

**Breaking — removed from `@tldraw/tldraw`:**
- `ARROW_LABEL_FONT_SIZES` — removed (now resolved via display values)
- `FONT_FAMILIES` — removed (use `getFontFamily(theme, font)` or display
values instead)
- `FONT_SIZES` — removed (now resolved via display values)
- `LABEL_FONT_SIZES` — removed (now resolved via display values)
- `STROKE_SIZES` — removed (now resolved via display values)
- `TEXT_PROPS` — removed (now resolved via display values)
- `useDefaultColorTheme()` — removed (use `editor.getCurrentTheme()` +
`useColorMode()` instead)

**Breaking — renamed / changed in `@tldraw/editor`:**
- `inferDarkMode` prop → `colorScheme` prop (`boolean` → `'light' |
'dark' | 'system'`)
- `useIsDarkMode()` → `useColorMode()` (returns `'dark' | 'light'`
instead of `boolean`)
- `SvgExportContext.themeId` → `SvgExportContext.colorMode` (`string` →
`'light' | 'dark'`)
- `TLFontFace` and `TLFontFaceSource` moved from `@tldraw/editor` to
`@tldraw/tlschema`
- `UserPreferencesManager` constructor: `inferDarkMode: boolean` →
`colorScheme: 'dark' | 'light' | 'system'`

**Breaking — changed in `@tldraw/tldraw`:**
- `PlainTextLabelProps`: `font` → `fontFamily`, `align` → `textAlign`,
`fill` removed, `verticalAlign` type narrowed to `'start' | 'middle' |
'end'`
- `RichTextLabelProps`: `font` → `fontFamily`, `align` → `textAlign`,
`fill` removed, `verticalAlign` type narrowed to `'start' | 'middle' |
'end'`
- `RichTextSVGProps`: `font` → `fontFamily`, `align` → `textAlign`,
added `lineHeight`, `verticalAlign` type narrowed to `'start' | 'middle'
| 'end'`
- `HighlightShapeOptions`: `overlayOpacity` and `underlayOpacity` moved
to display values
- `HighlightShapeUtil.toSvg()` and `toBackgroundSvg()` now require
`SvgExportContext` parameter
- `LineShapeUtil.toSvg()` now requires `SvgExportContext` parameter
- All shape option interfaces now extend `ShapeOptionsWithDisplayValues`
(adds `getDefaultDisplayValues` and `getCustomDisplayValues`)
- `EnumStyleProp.values` changed from `readonly T[]` to mutable `T[]`
(added `addValues()` / `removeValues()` methods)

**Breaking — schema changes:**
- `TLDefaultColorStyle` is now a computed type derived from
`TLThemeDefaultColors` (no longer a simple enum union)
- `TLDefaultFontStyle` is now `keyof TLThemeFonts & string` (no longer a
simple enum union)
- `TLNoteShapeProps.fontSizeAdjustment` changed from `number` to `null |
number`

### Code changes

| Section | LOC change |
| --- | --- |
| Core code | +3924 / -2575 |
| Tests | +369 / -100 |
| Automated files | +514 / -226 |
| Documentation | +1677 / -542 |
| Apps | +1 / -1 |
| Templates | +117 / -109 |

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Mime Čuvalo <mimecuvalo@gmail.com>
…ose (#8422)

In order to prevent stale state and leaked reactive subscriptions when
editors are remounted (e.g. React strict mode, deep links), this PR adds
cleanup for in-progress camera animations, user following, and open
menus to `Editor.dispose()`. Relates to #8396.

Previously, if the editor was disposed while a camera animation was
running, a user was being followed, or menus were open, these resources
would leak:

- **Camera animations**: `stopCameraAnimation()` was never called,
leaving tick listeners and `once('stop-camera-animation')` handlers
attached to the EventEmitter.
- **Following user**: The `react('update current page')` reactive
subscription would continue running after dispose since
`stopFollowingUser()` was never called — the most significant leak, as
this is a live `EffectScheduler` that keeps reacting to store changes.
- **Open menus**: Entries in the global `tlmenus` atom scoped to the
disposed editor's context would persist indefinitely.

### Change type

- [x] `bugfix`

### Test plan

1. In `apps/examples/src/index.tsx`, set `ENABLE_STRICT_MODE = true`
2. Run `yarn dev`, open any example
3. Start a camera animation (e.g. zoom to fit), then quickly navigate
away and back
4. Verify the editor works correctly after remount
5. Follow a user in a multiplayer room, then dispose the editor — verify
no console errors from stale reactive subscriptions

- [x] Unit tests (existing tests pass — 774 editor + 2208 tldraw)

### Release notes

- Fixed leaked camera animations, following subscriptions, and stale
menu state when the editor is disposed during active operations.

### Code changes

| Section    | LOC change |
| ---------- | ---------- |
| Core code  | +11 / -0   |
@pull pull Bot locked and limited conversation to collaborators Apr 2, 2026
@pull pull Bot added the ⤵️ pull label Apr 2, 2026
@pull pull Bot merged commit 2e3b042 into code:main Apr 2, 2026
@pull pull Bot had a problem deploying to deploy-staging April 2, 2026 21:13 Failure
@pull pull Bot had a problem deploying to deploy-production April 2, 2026 21:13 Failure
@pull pull Bot had a problem deploying to vsce publish April 2, 2026 21:13 Failure
@pull pull Bot had a problem deploying to bemo-canary April 2, 2026 21:13 Failure
@pull pull Bot had a problem deploying to bemo-canary April 2, 2026 21:13 Failure
@pull pull Bot had a problem deploying to deploy-staging April 2, 2026 21:13 Error
@pull pull Bot had a problem deploying to deploy-staging April 3, 2026 00:34 Failure
@pull pull Bot temporarily deployed to e2e-dotcom April 3, 2026 02:36 Inactive
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant