Skip to main content

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:
const color: Color = '#FF5733';

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

--textPrimaryColor
--textSecondaryColor
--backgroundPrimaryColor
--backgroundSecondaryColor
--borderColor
--hoverColor

Custom Theme Implementation

Creating a Custom Theme

Implement a complete custom theme:
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.
Verify that CSS variables are defined at the correct scope (:root or specific selectors). Check for typos in variable names.
Set the initial theme in a blocking script before the page renders, or use server-side rendering to inject the correct theme.
Ensure the document properties are being saved correctly. Check that edgelessColorTheme is included in the document schema.