Custom theme

Register a custom named theme and switch between themes.

import { useCallback, useMemo, useRef, useState } from 'react'
import {
	DEFAULT_THEME,
	TLDefaultColor,
	TLTheme,
	TLThemeFont,
	TLThemes,
	TLUiOverrides,
	Tldraw,
	TldrawUiButton,
	TldrawUiButtonLabel,
	toRichText,
} from 'tldraw'
import 'tldraw/tldraw.css'
import silkscreenBoldUrl from './custom-font/Silkscreen-Bold.ttf'
import silkscreenRegularUrl from './custom-font/Silkscreen-Regular.ttf'
import './custom-theme.css'

// [1]
// Extend the type system so TypeScript knows about our custom color and font.
// Because we pass `themes` to `<Tldraw>`, the custom names are
// registered automatically at store creation time.
declare module '@tldraw/tlschema' {
	interface TLThemeDefaultColors {
		pink: TLDefaultColor
	}
	interface TLThemeFonts {
		pixel: TLThemeFont
		cursive: TLThemeFont
	}
	// [7] Remove the "light-*" color variants from the palette.
	interface TLRemovedDefaultThemeColors {
		'light-violet': true
		'light-blue': true
		'light-green': true
		'light-red': true
	}
}

// Helper to create a full color entry from a base solid color
function makeColor(solid: string, semi: string, pattern: string): TLDefaultColor {
	return {
		solid,
		semi,
		pattern,
		fill: solid,
		linedFill: semi,
		frameHeadingStroke: solid,
		frameHeadingFill: semi,
		frameStroke: solid,
		frameFill: semi,
		frameText: solid,
		noteFill: semi,
		noteText: solid,
		highlightSrgb: solid,
		highlightP3: solid,
	}
}

// [2]
const pinkLight = makeColor('#e91e8c', '#fce4f2', '#f06baf')
const pinkDark = makeColor('#f06baf', '#3d1a2e', '#e91e8c')

// [8] Custom font — use a local font loaded from a bundled TTF file.
// The `icon` field provides a React element for the style panel button.
const pixelFont: TLThemeFont = {
	fontFamily: "'Silkscreen', sans-serif",
	icon: <div style={{ fontFamily: 'Silkscreen, sans-serif', fontSize: 16, lineHeight: 1 }}>Aa</div>,
	faces: [
		{
			family: 'Silkscreen',
			src: { url: silkscreenRegularUrl },
			weight: 'normal',
			style: 'normal',
		},
		{
			family: 'Silkscreen',
			src: { url: silkscreenBoldUrl },
			weight: 'bold',
			style: 'normal',
		},
	],
}

// Custom font — use a Google Font loaded via full URLs.
const cursiveFont: TLThemeFont = {
	fontFamily: "'Comic Neue', cursive",
	icon: <div style={{ fontFamily: "'Comic Neue', cursive", fontSize: 16, lineHeight: 1 }}>Aa</div>,
	faces: [
		{
			family: 'Comic Neue',
			src: {
				url: 'https://fonts.gstatic.com/s/comicneue/v8/4UaErEJDsxBrF37olUeD_wHLwpteLwtHJlc.woff2',
				format: 'woff2',
			},
			weight: 'normal',
			style: 'normal',
		},
		{
			family: 'Comic Neue',
			src: {
				url: 'https://fonts.gstatic.com/s/comicneue/v8/4UaFrEJDsxBrF37olUeD96_RTplUKylCNlcw_Q.woff2',
				format: 'woff2',
			},
			weight: 'bold',
			style: 'normal',
		},
	],
}

// [10] Build a reduced font palette: drop "serif", keep the rest, add custom fonts.
const { serif: _serif, ...keptFonts } = DEFAULT_THEME.fonts
const customFonts = { ...keptFonts, pixel: pixelFont, cursive: cursiveFont } as TLTheme['fonts']

// [11] Build a reduced color palette: drop "light-*" variants, add "pink".
function colorsWithoutLightVariants(base: Record<string, unknown>, pink: TLDefaultColor) {
	const {
		'light-violet': _lv,
		'light-blue': _lb,
		'light-green': _lg,
		'light-red': _lr,
		...kept
	} = base
	return { ...kept, pink } as TLTheme['colors']['light']
}

// [12] Translation overrides so the style panel shows human-readable names
// for our custom colors and fonts instead of raw keys like "color-style.pink".
const uiOverrides: TLUiOverrides = {
	translations: {
		en: {
			'color-style.pink': 'Pink',
			'font-style.pixel': 'Pixel',
			'font-style.cursive': 'Cursive',
		},
	},
}

// [3] Defaults for the adjustable theme values
const DEFAULTS = {
	fontSize: 16,
	lineHeight: 1.35,
	strokeWidth: 2,
}

export default function CustomThemeExample() {
	const [fontSize, setFontSize] = useState(DEFAULTS.fontSize)
	const [lineHeight, setLineHeight] = useState(DEFAULTS.lineHeight)
	const [strokeWidth, setStrokeWidth] = useState(DEFAULTS.strokeWidth)

	// [4] Customize the default theme: add the custom "pink" color,
	// custom fonts, and merge slider overrides so adjustments apply to both modes.
	const themes = useMemo<Partial<TLThemes>>(() => {
		return {
			default: {
				id: 'default',
				fontSize,
				lineHeight,
				strokeWidth,
				fonts: customFonts,
				colors: {
					light: colorsWithoutLightVariants(DEFAULT_THEME.colors.light, pinkLight),
					dark: colorsWithoutLightVariants(DEFAULT_THEME.colors.dark, pinkDark),
				},
			},
		}
	}, [fontSize, lineHeight, strokeWidth])

	return (
		<div className="tldraw__editor">
			<Tldraw
				persistenceKey="custom-theme-example"
				themes={themes}
				overrides={uiOverrides}
				onMount={(editor) => {
					if (editor.getCurrentPageShapeIds().size > 0) return

					editor.createShape({
						type: 'geo',
						x: 100,
						y: 100,
						props: { w: 200, h: 200, color: 'red' },
					})
					editor.createShape({
						type: 'geo',
						x: 350,
						y: 100,
						props: {
							w: 200,
							h: 200,
							color: 'blue',
							geo: 'ellipse',
							richText: toRichText('Hello'),
						},
					})
					// [5] Use the custom "pink" color declared in our themes
					editor.createShape({
						type: 'geo',
						x: 600,
						y: 100,
						props: { w: 200, h: 200, color: 'pink', geo: 'diamond' },
					})
					editor.createShape({
						type: 'text',
						x: 100,
						y: 350,
						props: { richText: toRichText('Theme text'), size: 'l' },
					})
					// [9] Use the custom fonts declared in our themes
					editor.createShape({
						type: 'text',
						x: 350,
						y: 350,
						props: { richText: toRichText('Pixel font!'), size: 'l', font: 'pixel' },
					})
					editor.createShape({
						type: 'text',
						x: 600,
						y: 350,
						props: { richText: toRichText('Cursive font!'), size: 'l', font: 'cursive' },
					})
					editor.createShape({
						type: 'note',
						x: 100,
						y: 500,
						props: {
							color: 'black',
							richText: toRichText('A sticky note'),
						},
					})
				}}
			>
				<ThemeControls
					fontSize={fontSize}
					onFontSizeChange={setFontSize}
					lineHeight={lineHeight}
					onLineHeightChange={setLineHeight}
					strokeWidth={strokeWidth}
					onStrokeWidthChange={setStrokeWidth}
				/>
			</Tldraw>
		</div>
	)
}

// [6] A panel with sliders to adjust theme values in real time.
function ThemeControls({
	fontSize,
	onFontSizeChange,
	lineHeight,
	onLineHeightChange,
	strokeWidth,
	onStrokeWidthChange,
}: {
	fontSize: number
	onFontSizeChange(v: number): void
	lineHeight: number
	onLineHeightChange(v: number): void
	strokeWidth: number
	onStrokeWidthChange(v: number): void
}) {
	return (
		<div className="tlui-menu custom-theme-toolbar" onPointerDown={(e) => e.stopPropagation()}>
			<ThemeSlider
				label="Font size"
				value={fontSize}
				onChange={onFontSizeChange}
				min={8}
				max={32}
				step={1}
				defaultValue={DEFAULTS.fontSize}
			/>
			<ThemeSlider
				label="Line height"
				value={lineHeight}
				onChange={onLineHeightChange}
				min={1}
				max={2}
				step={0.05}
				defaultValue={DEFAULTS.lineHeight}
			/>
			<ThemeSlider
				label="Stroke width"
				value={strokeWidth}
				onChange={onStrokeWidthChange}
				min={0.5}
				max={6}
				step={0.25}
				defaultValue={DEFAULTS.strokeWidth}
			/>

			<TldrawUiButton
				type="low"
				onClick={() => {
					onFontSizeChange(DEFAULTS.fontSize)
					onLineHeightChange(DEFAULTS.lineHeight)
					onStrokeWidthChange(DEFAULTS.strokeWidth)
				}}
			>
				<TldrawUiButtonLabel>Reset to defaults</TldrawUiButtonLabel>
			</TldrawUiButton>
		</div>
	)
}

function ThemeSlider({
	label,
	value,
	onChange,
	min,
	max,
	step,
	defaultValue,
}: {
	label: string
	value: number
	onChange(v: number): void
	min: number
	max: number
	step: number
	defaultValue: number
}) {
	const [localValue, setLocalValue] = useState(value)
	const isDragging = useRef(false)

	// sync from parent when not actively dragging
	if (!isDragging.current && localValue !== value) {
		setLocalValue(value)
	}

	const isDefault = localValue === defaultValue

	const handleInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
		isDragging.current = true
		setLocalValue(Number(e.target.value))
	}, [])

	const handleCommit = useCallback(() => {
		if (!isDragging.current) return
		isDragging.current = false
		onChange(localValue)
	}, [onChange, localValue])

	return (
		<div className="custom-theme-slider">
			<div className="custom-theme-slider__header">
				<span className="custom-theme-slider__label">{label}</span>
				<span className="custom-theme-slider__value" data-default={isDefault}>
					{localValue % 1 === 0 ? localValue : localValue.toFixed(2)}
				</span>
			</div>
			<input
				type="range"
				min={min}
				max={max}
				step={step}
				value={localValue}
				onChange={handleInput}
				onPointerUp={handleCommit}
			/>
		</div>
	)
}

/*

[1]
Extend `TLThemeDefaultColors` and `TLThemeFonts` interfaces via module augmentation
to add a custom "pink" color and custom "pixel" / "cursive" fonts. Because
`themes` is passed to `<Tldraw>`, these names are registered
automatically.

[2]
Define color entries for light and dark variants. Each theme definition
needs a full `TLDefaultColor` entry for the custom color in both palettes.

[3]
Default values for the adjustable theme properties. These match the defaults
in `DEFAULT_THEME`.

[4]
The `themes` object is recomputed whenever a slider changes.
Because `Tldraw` accepts `themes` as a prop, updating the object
triggers a reactive theme change — shapes immediately re-render with the
new values. The active color mode (light or dark) is determined by the
user's color scheme preference.

[5]
Create a shape using the custom "pink" color. Because the theme definition
declares the color, it passes validation automatically.

[6]
A panel with sliders for `fontSize`, `lineHeight`, and `strokeWidth`.
Adjusting these values lets you see in real time how theme values affect
shape rendering. Try drawing some shapes with different sizes and then
moving the stroke width slider!

[8]
Define a custom font with a fontFamily CSS string and font face definitions
for loading. The `faces` array contains `TLFontFace` entries with URLs to
the actual font files (here bundled locally via import). For system fonts
(like Arial or Georgia), you can omit `faces` entirely — they don't need
loading. The `icon` field provides a React element that the style panel
uses as the button icon for this font — here a letter "A" rendered in
the custom font itself.

[9]
Create shapes using the custom "pixel" and "cursive" fonts. They show up
in the style panel alongside the remaining built-in fonts (draw, sans, mono).

[10]
Demonstrate removing a built-in font: destructure out "serif" from the
default font palette and spread the rest. The serif font option disappears
from the style panel. Two custom fonts are added in its place.

[11]
Demonstrate removing built-in colors: destructure out the "light-*" color
variants from the default palette. They won't appear in the style panel.
The custom "pink" color is added in their place.

[12]
Translation overrides provide human-readable names for custom style values.
Without these, the tooltip for a custom color like "pink" would show the
raw translation key "color-style.pink". Pass an `overrides` prop to `<Tldraw>`
with a `translations` map keyed by locale code (here just `en`).

*/

You can register themes beyond light and dark by passing a themes prop with additional entries. Use the initialTheme prop to set the initial theme; switch at runtime via editor.setCurrentTheme(). This example adds a "my-brand" theme with custom colors and provides buttons to switch between all three themes.

Is this page helpful?
Prev
Format rich text on multiple shapes
Next
Multiple themes