Skip to content

Commit 82cec81

Browse files
authored
feat(nuxt-ui): color mode aware token resolution via data-theme (#598)
1 parent 19db029 commit 82cec81

11 files changed

Lines changed: 370 additions & 38 deletions

File tree

docs/content/3.guides/7.styling.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,66 @@ The module automatically detects your CSS framework:
7979
- **UnoCSS:** Active if you enable `@unocss/nuxt`.
8080
- **Nuxt UI v3:** Automatically imports colors and theme variables.
8181

82+
## Theme Switching (`data-theme`)
83+
84+
By default, OG images render with dark-mode semantic tokens (e.g. Nuxt UI's `bg-inverted`, `text-default`). To opt into theme-aware token resolution, set `data-theme` on the **root element** of your component. Tailwind v4 only.
85+
86+
### Static `data-theme`
87+
88+
Hard-code a theme on the root element:
89+
90+
```vue [components/OgImage/MyImage.satori.vue]
91+
<template>
92+
<div data-theme="light" class="w-full h-full bg-default text-default p-10">
93+
<div class="bg-inverted text-inverted p-6 rounded-lg">
94+
Light mode tokens
95+
</div>
96+
</div>
97+
</template>
98+
```
99+
100+
Semantic tokens like `bg-inverted`, `text-muted`, `border-accented` resolve against the chosen theme: `data-theme="light"` produces light-mode values, `data-theme="dark"` produces dark-mode values.
101+
102+
### Dynamic `data-theme` (per-image color mode)
103+
104+
Bind `:data-theme` to a prop to swap themes per OG image:
105+
106+
```vue [components/OgImage/MyImage.satori.vue]
107+
<script lang="ts" setup>
108+
defineProps<{ colorMode?: 'light' | 'dark' }>()
109+
</script>
110+
111+
<template>
112+
<div :data-theme="colorMode" class="w-full h-full bg-default text-default p-10">
113+
<div class="bg-inverted text-inverted p-6 rounded-lg">
114+
hello {{ colorMode }}
115+
</div>
116+
</div>
117+
</template>
118+
```
119+
120+
```vue [pages/light.vue]
121+
<script setup lang="ts">
122+
defineOgImage('MyImage', { colorMode: 'light' })
123+
</script>
124+
```
125+
126+
```vue [pages/dark.vue]
127+
<script setup lang="ts">
128+
defineOgImage('MyImage', { colorMode: 'dark' })
129+
</script>
130+
```
131+
132+
When the build detects a `:data-theme` binding on the root element, it resolves classes for both themes at build time and emits paired `bg-[#...] dark:bg-[#...]` arbitrary classes. The renderer then picks the correct value based on the `colorMode` prop you pass to `defineOgImage`.
133+
134+
::note
135+
The dynamic binding only works on the **root element** of the OG component. Nested elements inherit the resolved theme from the build pass.
136+
::
137+
138+
::tip
139+
Templates that don't set `data-theme` keep the existing dark-mode default; this feature is opt-in and non-breaking.
140+
::
141+
82142
## Inline Styles
83143

84144
Use the `:style` binding for dynamic values that change based on props.

docs/content/4.integrations/2.color-mode.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,7 @@ export default defineNuxtConfig({
2323
},
2424
})
2525
```
26+
27+
## Per-image theme switching
28+
29+
If you need to render the same component in different themes for different pages (e.g. a light variant and a dark variant of a hero image), bind `:data-theme` on the component's root element to a `colorMode` prop. See [Theme Switching](/docs/og-image/guides/styling#theme-switching-data-theme) in the styling guide.

src/build/css/css-utils.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,16 @@ function isRootOrHost(selectors: any[]): boolean {
401401
c.type === 'pseudo-class' && (c.kind === 'root' || c.kind === 'host'))
402402
}
403403

404+
/**
405+
* Check if a selector list contains a bare `.dark` or `.light` theme class
406+
* (e.g., `.dark { ... }`). These are emitted by libraries like @nuxt/ui as
407+
* the canonical dark-mode rule and act as :root-equivalent for theme vars.
408+
*/
409+
function isBareThemeClass(selectors: any[]): boolean {
410+
return selectorsContain(selectors, c =>
411+
c.type === 'class' && (c.name === 'dark' || c.name === 'light'))
412+
}
413+
404414
function isUniversal(selectors: any[]): boolean {
405415
return selectorsContain(selectors, c => c.type === 'universal')
406416
}
@@ -526,7 +536,7 @@ export async function extractVarsFromCss(css: string): Promise<ExtractedCssVars>
526536
const selectors = rule.value.selectors
527537
const declarations = rule.value.declarations
528538

529-
if (isRootOrHost(selectors)) {
539+
if (isRootOrHost(selectors) || isBareThemeClass(selectors)) {
530540
const vars = extractCustomProps(declarations)
531541
if (vars.size === 0)
532542
return undefined

0 commit comments

Comments
 (0)