Theme Customization
AFFiNE provides a comprehensive theming system that allows you to customize the visual appearance of the editor and application. This guide covers theme architecture, color schemes, and customization techniques.
Overview
The theme system in AFFiNE is built on three main layers:
App Theme Global application theme (light/dark mode)
Editor Theme BlockSuite editor-specific theming
Edgeless Theme Canvas/whiteboard specific themes
Color Schemes
Color Scheme Enum
AFFiNE uses a standardized color scheme enumeration:
export enum ColorScheme {
Dark = 'dark' ,
Light = 'light' ,
}
See: ~/workspace/source/blocksuite/affine/model/src/themes/color.ts:3
Color Type Definition
Colors can be defined in multiple formats:
Simple String
Normal Object
Dark/Light Object
const color : Color = '#FF5733' ;
const color : Color = {
normal: '#FF5733'
};
const color : Color = {
[ColorScheme.Dark]: '#FF5733' ,
[ColorScheme.Light]: '#33FF57'
};
Resolving Colors
Use the resolveColor function to get the appropriate color for a scheme:
import { resolveColor , ColorScheme } from '@blocksuite/affine/model' ;
const color = {
[ColorScheme.Dark]: '#2D2D2D' ,
[ColorScheme.Light]: '#FFFFFF'
};
const darkColor = resolveColor ( color , ColorScheme . Dark , 'transparent' );
// Returns: '#2D2D2D'
const lightColor = resolveColor ( color , ColorScheme . Light , 'transparent' );
// Returns: '#FFFFFF'
The third parameter is a fallback value used when the color cannot be resolved.
See: ~/workspace/source/blocksuite/affine/model/src/themes/color.ts:26
App Theme Service
Theme Entity
The AppTheme entity manages application-wide theme state:
import { AppTheme } from '@affine/core/modules/theme' ;
import { ColorScheme } from '@blocksuite/affine/model' ;
export class AppTheme extends Entity {
theme$ = new LiveData < string | undefined >( undefined );
themeSignal : Signal < ColorScheme >;
constructor () {
super ();
const { signal , cleanup } = createSignalFromObservable < ColorScheme >(
this . theme$ . map ( theme =>
theme === 'dark' ? ColorScheme . Dark : ColorScheme . Light
),
ColorScheme . Light
);
this . themeSignal = signal ;
this . disposables . push ( cleanup );
}
}
See: ~/workspace/source/packages/frontend/core/src/modules/theme/entities/theme.ts:6
Theme Service
Access and manage themes through the service:
import { AppThemeService } from '@affine/core/modules/theme' ;
export class AppThemeService extends Service {
appTheme = this . framework . createEntity ( AppTheme );
}
The theme service uses reactive data streams to propagate theme changes throughout the application.
See: ~/workspace/source/packages/frontend/core/src/modules/theme/services/theme.ts:5
BlockSuite Theme Extension
Theme View Extension
Integrate themes into BlockSuite editor views:
import {
ViewExtensionProvider ,
type ViewExtensionContext
} from '@blocksuite/affine/ext-loader' ;
import { FrameworkProvider } from '@toeverything/infra' ;
import { z } from 'zod' ;
const optionsSchema = z . object ({
framework: z . instanceof ( FrameworkProvider ). optional (),
});
export class AffineThemeViewExtension extends ViewExtensionProvider <
z . infer < typeof optionsSchema >
> {
override name = 'affine-view-theme' ;
override schema = optionsSchema ;
override setup (
context : ViewExtensionContext ,
options ?: z . infer < typeof optionsSchema >
) {
super . setup ( context , options );
const framework = options ?. framework ;
if ( ! framework ) return ;
if ( this . isPreview ( context . scope )) {
context . register ( getPreviewThemeExtension ( framework ));
} else {
context . register ( getThemeExtension ( framework ));
}
}
}
See: ~/workspace/source/packages/frontend/core/src/blocksuite/view-extensions/theme/index.ts:16
Creating Theme Extensions
Implement custom theme extensions with lifecycle management:
import { LifeCycleWatcher } from '@blocksuite/affine/std' ;
import type { ThemeExtension } from '@blocksuite/affine/shared/services' ;
import type { Signal } from '@blocksuite/affine/shared/utils' ;
export function getThemeExtension (
framework : FrameworkProvider
) : typeof LifeCycleWatcher {
class AffineThemeExtension
extends LifeCycleWatcher
implements ThemeExtension
{
static override readonly key = 'affine-theme' ;
private readonly themes : Map < string , Signal < ColorScheme >> = new Map ();
protected readonly disposables : (() => void )[] = [];
static override setup ( di : Container ) {
super . setup ( di );
di . override ( ThemeExtensionIdentifier , AffineThemeExtension , [
StdIdentifier ,
]);
}
getAppTheme () {
const keyName = 'app-theme' ;
const cache = this . themes . get ( keyName );
if ( cache ) return cache ;
const theme$ : Observable < ColorScheme > = framework
. get ( AppThemeService )
. appTheme . theme$ . map ( theme => {
return theme === ColorScheme . Dark
? ColorScheme . Dark
: ColorScheme . Light ;
});
const { signal : themeSignal , cleanup } =
createSignalFromObservable < ColorScheme >( theme$ , ColorScheme . Light );
this . disposables . push ( cleanup );
this . themes . set ( keyName , themeSignal );
return themeSignal ;
}
override unmounted () {
this . dispose ();
}
dispose () {
this . disposables . forEach ( dispose => dispose ());
}
}
return AffineThemeExtension ;
}
See: ~/workspace/source/packages/frontend/core/src/blocksuite/view-extensions/theme/theme.ts:18
Edgeless Theme Support
Edgeless (whiteboard) views support per-document theme customization:
getEdgelessTheme ( docId ?: string ) {
const doc =
( docId && framework . get ( DocsService ). list . doc$ ( docId ). getValue ()) ||
framework . get ( DocService ). doc ;
const cache = this . themes . get ( doc . id );
if ( cache ) return cache ;
const appTheme$ = framework . get ( AppThemeService ). appTheme . theme$ ;
const docTheme$ = doc . properties$ . map (
props => props . edgelessColorTheme || 'system'
);
const theme$ : Observable < ColorScheme > = combineLatest ([
appTheme$ ,
docTheme$ ,
]). pipe (
map (([ appTheme , docTheme ]) => {
const theme = docTheme === 'system' ? appTheme : docTheme ;
return theme === ColorScheme . Dark
? ColorScheme . Dark
: ColorScheme . Light ;
})
);
const { signal : themeSignal , cleanup } =
createSignalFromObservable < ColorScheme >( theme$ , ColorScheme . Light );
this . disposables . push ( cleanup );
this . themes . set ( doc . id , themeSignal );
return themeSignal ;
}
Edgeless themes can be set to ‘system’ to follow the app theme, or overridden per-document.
See: ~/workspace/source/packages/frontend/core/src/blocksuite/view-extensions/theme/theme.ts:57
Theme CSS Variables
Using CSS Variables
AFFiNE themes use CSS custom properties for dynamic styling:
import { cssVar } from '@toeverything/theme' ;
import { globalStyle } from '@vanilla-extract/css' ;
globalStyle ( 'body' , {
color: cssVar ( 'textPrimaryColor' ),
fontFamily: cssVar ( 'fontFamily' ),
fontSize: cssVar ( 'fontBase' ),
});
See: ~/workspace/source/packages/frontend/component/src/theme/theme.css.ts:4
Available Theme Variables
Colors
Typography
Spacing
--textPrimaryColor
--textSecondaryColor
--backgroundPrimaryColor
--backgroundSecondaryColor
--borderColor
--hoverColor
--fontFamily
--fontBase
--fontSm
--fontXs
--lineHeight
--spacing
--paddingSm
--paddingMd
--paddingLg
--marginSm
--marginMd
Custom Theme Implementation
Creating a Custom Theme
Implement a complete custom theme:
theme-definition.ts
theme-provider.ts
styles.css
import { ColorScheme } from '@blocksuite/affine/model' ;
export const customTheme = {
name: 'Custom Theme' ,
colors: {
[ColorScheme.Light]: {
primary: '#007AFF' ,
secondary: '#5856D6' ,
background: '#FFFFFF' ,
surface: '#F5F5F5' ,
text: '#000000' ,
textSecondary: '#666666' ,
},
[ColorScheme.Dark]: {
primary: '#0A84FF' ,
secondary: '#5E5CE6' ,
background: '#1C1C1E' ,
surface: '#2C2C2E' ,
text: '#FFFFFF' ,
textSecondary: '#999999' ,
},
},
};
Theme Hooks and Utilities
React Theme Hooks
Use React hooks to access theme state:
import { useThemeValue } from '@affine/component' ;
import { useThemeColorMeta } from '@affine/component/hooks' ;
function MyComponent () {
const theme = useThemeValue ();
// Returns: 'light' | 'dark'
const colorMeta = useThemeColorMeta ();
// Returns theme color metadata
return (
< div style = {{ background : theme === 'dark' ? '#000' : '#fff' }} >
Theme : { theme }
</ div >
);
}
Theme Signals
Use signals for reactive theme updates:
import { createSignalFromObservable } from '@blocksuite/affine/shared/utils' ;
import type { Signal } from '@preact/signals-core' ;
const { signal : themeSignal , cleanup } = createSignalFromObservable < ColorScheme >(
theme$ ,
ColorScheme . Light
);
// Use the signal
const currentTheme = themeSignal . value ;
// Clean up when done
cleanup ();
Mobile Theme Support
Capacitor Theme Plugin
For mobile platforms, use the Capacitor theme plugin:
import { registerPlugin } from '@capacitor/core' ;
import type { AffineThemePlugin } from './definitions' ;
const AffineTheme = registerPlugin < AffineThemePlugin >( 'AffineTheme' );
// Use the plugin
await AffineTheme . setTheme ({ theme: 'dark' });
const currentTheme = await AffineTheme . getTheme ();
See: ~/workspace/source/packages/frontend/apps/android/src/plugins/affine-theme/index.ts:5
Preview Theme Extension
For preview contexts (read-only views), use simplified theme extensions:
import { getPreviewThemeExtension } from '@affine/core/blocksuite/view-extensions/theme/preview-theme' ;
if ( context . scope === 'preview-page' || context . scope === 'preview-edgeless' ) {
context . register ( getPreviewThemeExtension ( framework ));
}
Preview themes are optimized for performance and don’t include interactive features.
Theme Best Practices
Consistent Tokens Use CSS variables consistently across components for easy theme switching.
Accessibility Ensure sufficient contrast ratios in both light and dark themes (WCAG AA).
Performance Cache theme signals to avoid unnecessary recalculations and re-renders.
Cleanup Always clean up observables and signals in lifecycle hooks.
Testing Themes
Theme Switching Test
import { test , expect } from '@playwright/test' ;
test ( 'theme switching works' , async ({ page }) => {
await page . goto ( '/workspace' );
// Check initial theme
const body = page . locator ( 'body' );
await expect ( body ). toHaveAttribute ( 'data-theme' , 'light' );
// Switch to dark theme
await page . click ( '[data-testid="theme-toggle"]' );
await expect ( body ). toHaveAttribute ( 'data-theme' , 'dark' );
// Verify CSS variables
const bgColor = await body . evaluate (( el ) => {
return getComputedStyle ( el ). getPropertyValue ( '--backgroundPrimaryColor' );
});
expect ( bgColor ). toBeTruthy ();
});
Troubleshooting
Ensure you’re properly subscribing to theme observables and cleaning up subscriptions. Check that the theme signal is being propagated through the component tree.
CSS variables not working
Verify that CSS variables are defined at the correct scope (:root or specific selectors). Check for typos in variable names.
Flash of wrong theme on load
Set the initial theme in a blocking script before the page renders, or use server-side rendering to inject the correct theme.
Edgeless theme not persisting
Ensure the document properties are being saved correctly. Check that edgelessColorTheme is included in the document schema.