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:
- Themes —
lightTheme,darkThemeanddefineTheme. - Shared configuration — one reactive configuration for all diagrams of an app.
- Configuration —
setConfigchanges a running diagram.
Plain JavaScript
<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.
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;
}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.
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.
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.
<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.
// 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.
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.
<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 toshared.themeredraws 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 innuxt.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-themefrom a small inline script in the page head, before the app loads.
Variations
- Follow the system live. Listen to
matchMedia('(prefers-color-scheme: dark)')forchangeand 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-colorand the others in a stylesheet, with no script. Markers do not follow a stylesheet-only color; see Themes.