Skip to content

Theme Switching ​

A diagram that follows the look of the page: light, dark and a custom palette, chosen by the user, remembered, and defaulting to the operating system's preference. In Vue and Nuxt one switch recolors every diagram on the page at once.

What it uses:

Plain JavaScript ​

html
<div class="toolbar" id="toolbar"></div>
<div id="diagram">
  <div class="card" id="a" style="left: 20px">Source</div>
  <div class="card" id="b" style="left: 380px">Target</div>
</div>

The page's own colors are CSS variables switched by the data-theme attribute; the diagram's colors come from the theme.

css
body {
  margin: 0;
  padding: 16px;
  font:
    14px system-ui,
    sans-serif;
  background: var(--page);
  color: var(--text);
  transition: background 0.2s;
}
body[data-theme='light'] {
  --page: #f8fafc;
  --card: #fff;
  --text: #1c1e2b;
  --border: #cbd5e1;
}
body[data-theme='dark'],
body[data-theme='midnight'] {
  --page: #14162a;
  --card: #1b1d2b;
  --text: #e6e8f2;
  --border: #3b3f5c;
}
.toolbar {
  display: flex;
  gap: 8px;
  margin-bottom: 12px;
}
button {
  padding: 6px 12px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--card);
  color: var(--text);
  cursor: pointer;
}
button[aria-pressed='true'] {
  border-color: #6366f1;
}
#diagram {
  position: relative;
  height: 200px;
}
.card {
  position: absolute;
  top: 60px;
  width: 140px;
  padding: 14px;
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: 8px;
}
ts
import {
  createVisualLinker,
  darkTheme,
  defineTheme,
  lightTheme,
} from '@macrulez/visual-linker-core'

const themes = {
  light: lightTheme,
  dark: darkTheme,
  midnight: defineTheme(
    {
      line: '#8b9bff',
      lineHover: '#c4ccff',
      lineSelected: '#ffd479',
      portFill: '#14162a',
      portStroke: '#8b9bff',
      labelBorder: '#8b9bff',
    },
    darkTheme,
  ),
}
type ThemeName = keyof typeof themes

const stored = localStorage.getItem('diagram-theme') as ThemeName | null
const preferred: ThemeName = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'

const diagram = document.querySelector<HTMLElement>('#diagram')!
const linker = createVisualLinker(diagram, {
  theme: themes[stored && stored in themes ? stored : preferred],
  lines: { curve: 'bezier', hover: { width: 3 } },
  interaction: { hover: true },
  markers: { end: { shape: 'arrow' } },
  labels: { fontSize: 12 },
})
linker.setBlocks([
  { id: 'a', el: document.querySelector<HTMLElement>('#a')! },
  { id: 'b', el: document.querySelector<HTMLElement>('#b')! },
])
linker.setConnections([
  {
    id: 'a-b',
    from: { blockId: 'a' },
    to: { blockId: 'b' },
    labels: [{ id: 'l', position: 'middle', text: 'sync' }],
  },
])

const toolbar = document.querySelector<HTMLElement>('#toolbar')!
function apply(name: ThemeName) {
  document.body.dataset.theme = name
  linker.setConfig({ theme: themes[name] })
  localStorage.setItem('diagram-theme', name)
  toolbar
    .querySelectorAll('button')
    .forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.theme === name)))
}
toolbar.innerHTML = (Object.keys(themes) as ThemeName[])
  .map((n) => `<button data-theme="${n}">${n}</button>`)
  .join('')
toolbar.addEventListener('click', (e) => {
  const name = (e.target as HTMLElement).dataset.theme as ThemeName | undefined
  if (name) apply(name)
})
apply(stored && stored in themes ? stored : preferred)

Vue ​

Install the shared configuration once, with the plugin. Every <VisualLinker> below it starts from that configuration, merged under its own config.

ts
import { createApp } from 'vue'
import { VisualLinkerPlugin, lightTheme } from '@macrulez/visual-linker-vue'
import App from './ThemedPage.vue'

createApp(App)
  .use(VisualLinkerPlugin, { config: { theme: lightTheme } })
  .mount('#app')

A composable owns the choice and writes it to the shared configuration. Assigning shared.theme redraws every diagram on the page.

ts
import { ref, watch } from 'vue'
import {
  darkTheme,
  defineTheme,
  lightTheme,
  useVisualLinkerConfig,
} from '@macrulez/visual-linker-vue'

const themes = {
  light: lightTheme,
  dark: darkTheme,
  midnight: defineTheme(
    {
      line: '#8b9bff',
      lineHover: '#c4ccff',
      lineSelected: '#ffd479',
      portFill: '#14162a',
      portStroke: '#8b9bff',
      labelBorder: '#8b9bff',
    },
    darkTheme,
  ),
}
export type ThemeName = keyof typeof themes

export function useDiagramTheme() {
  const shared = useVisualLinkerConfig()
  const stored = localStorage.getItem('diagram-theme') as ThemeName | null
  const preferred: ThemeName = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
  const name = ref<ThemeName>(stored && stored in themes ? stored : preferred)

  watch(
    name,
    (value) => {
      shared.theme = themes[value]
      document.body.dataset.theme = value
      localStorage.setItem('diagram-theme', value)
    },
    { immediate: true },
  )

  return { name, names: Object.keys(themes) as ThemeName[] }
}

The page just uses it. The second diagram has its own config for the curve, and still follows the theme.

vue
<script setup lang="ts">
import { VisualLinker, vVlBlock } from '@macrulez/visual-linker-vue'
import { useDiagramTheme } from './useDiagramTheme'

const { name, names } = useDiagramTheme()

const connections = [
  {
    id: 'a-b',
    from: { blockId: 'a' },
    to: { blockId: 'b' },
    labels: [{ id: 'l', position: 'middle', text: 'sync' }],
  },
]
const second = [{ id: 'c-d', from: { blockId: 'c' }, to: { blockId: 'd' } }]
</script>

<template>
  <div class="toolbar">
    <button v-for="n in names" :key="n" :aria-pressed="name === n" @click="name = n">
      {{ n }}
    </button>
  </div>

  <VisualLinker class="diagram" :connections="connections">
    <div v-vl-block="'a'" class="card" style="left: 20px">Source</div>
    <div v-vl-block="'b'" class="card" style="left: 380px">Target</div>
  </VisualLinker>

  <VisualLinker class="diagram" :connections="second" :config="{ lines: { curve: 'smoothstep' } }">
    <div v-vl-block="'c'" class="card" style="left: 20px">Queue</div>
    <div v-vl-block="'d'" class="card" style="left: 380px">Worker</div>
  </VisualLinker>
</template>

<style>
body {
  margin: 0;
  padding: 16px;
  font:
    14px system-ui,
    sans-serif;
  background: var(--page);
  color: var(--text);
}
body[data-theme='light'] {
  --page: #f8fafc;
  --card: #fff;
  --text: #1c1e2b;
  --border: #cbd5e1;
}
body[data-theme='dark'],
body[data-theme='midnight'] {
  --page: #14162a;
  --card: #1b1d2b;
  --text: #e6e8f2;
  --border: #3b3f5c;
}
.toolbar {
  display: flex;
  gap: 8px;
  margin-bottom: 12px;
}
.toolbar button {
  padding: 6px 12px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--card);
  color: var(--text);
  cursor: pointer;
}
.toolbar button[aria-pressed='true'] {
  border-color: #6366f1;
}
.diagram {
  position: relative;
  height: 150px;
}
.card {
  position: absolute;
  top: 40px;
  width: 140px;
  padding: 14px;
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: 8px;
}
</style>

Nuxt ​

Set the module up as usual; the shared configuration is installed for you.

ts
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@macrulez/visual-linker-nuxt'],
})

The theme composable differs in two places: the choice lives in useState, so that the server and the client agree, and the stored preference is read in onMounted, since the server has no localStorage or matchMedia. Import the themes and useVisualLinkerConfig from @macrulez/visual-linker-vue, so add it as a direct dependency of your project.

ts
import {
  darkTheme,
  defineTheme,
  lightTheme,
  useVisualLinkerConfig,
} from '@macrulez/visual-linker-vue'

const themes = {
  light: lightTheme,
  dark: darkTheme,
  midnight: defineTheme(
    {
      line: '#8b9bff',
      lineHover: '#c4ccff',
      lineSelected: '#ffd479',
      portFill: '#14162a',
      portStroke: '#8b9bff',
      labelBorder: '#8b9bff',
    },
    darkTheme,
  ),
}
export type ThemeName = keyof typeof themes

export function useDiagramTheme() {
  const shared = useVisualLinkerConfig()
  const name = useState<ThemeName>('diagram-theme', () => 'light')

  onMounted(() => {
    const stored = localStorage.getItem('diagram-theme') as ThemeName | null
    const preferred: ThemeName = matchMedia('(prefers-color-scheme: dark)').matches
      ? 'dark'
      : 'light'
    name.value = stored && stored in themes ? stored : preferred
  })

  watch(
    name,
    (value) => {
      shared.theme = themes[value]
      if (import.meta.client) {
        document.body.dataset.theme = value
        localStorage.setItem('diagram-theme', value)
      }
    },
    { immediate: true },
  )

  return { name, names: Object.keys(themes) as ThemeName[] }
}

A page uses the composable like any other. The component and the directive need no import.

vue
<script setup lang="ts">
const { name, names } = useDiagramTheme()

const connections = [{ id: 'a-b', from: { blockId: 'a' }, to: { blockId: 'b' } }]
</script>

<template>
  <div class="toolbar">
    <button v-for="n in names" :key="n" :aria-pressed="name === n" @click="name = n">
      {{ n }}
    </button>
  </div>

  <VisualLinker class="diagram" :connections="connections">
    <div v-vl-block="'a'" class="card" style="left: 20px">Source</div>
    <div v-vl-block="'b'" class="card" style="left: 380px">Target</div>
  </VisualLinker>
</template>

How it works ​

  • Tokens, not styles. A theme is a set of color tokens written to CSS variables on the diagram, so switching it recolors lines, ports and labels at once. Markers follow the color of their line.
  • Plain JavaScript. linker.setConfig({ theme }) changes a running diagram. Nothing needs to be re-created.
  • Vue and Nuxt. useVisualLinkerConfig() returns the app's shared configuration, a reactive object. Assigning to shared.theme redraws every diagram under it.
  • A custom palette. defineTheme(overrides, base) copies a base theme and replaces the tokens you give. The label border is its own token, labelBorder, so a palette that recolors the line usually sets it as well.
  • Assigning replaces the theme. shared.theme = themes[value] replaces the whole object, including any tokens set earlier, for example in nuxt.config.ts. To change one token only, assign it: shared.theme = { ...shared.theme, line: '#8b9bff' }.
  • The first paint. The stored choice is applied right after mounting, so a user who prefers the other theme sees one frame of the default. To avoid it, set data-theme from a small inline script in the page head, before the app loads.

Variations ​

  • Follow the system live. Listen to matchMedia('(prefers-color-scheme: dark)') for change and update the choice while the user has not picked one explicitly.
  • Only CSS. Because a theme writes CSS variables, @media (prefers-color-scheme: dark) can set --vl-line-color and the others in a stylesheet, with no script. Markers do not follow a stylesheet-only color; see Themes.