Repository: shikijs/shiki
Pull request #1028 (2025-06-03 07:46:32)

feat: add support for CSS `light-dark()` function in dual themes
<!-- DO NOT IGNORE THE TEMPLATE!

Thank you for contributing!

Before submitting the PR, please make sure you do the following:

- Read the [Contributing Guide](https://github.com/shikijs/shiki/blob/main/CONTRIBUTING.md).
- Check that there isn't already a PR that solves the problem the same way to avoid creating a duplicate.
- Provide a description in this PR that addresses **what** the PR is solving, or reference the issue that it solves (e.g. `fixes #123`).
- Ideally, include relevant tests that fail without this PR but pass with it.

-->

### Description

<!-- Please insert your description here and provide especially info about the "what" this PR is solving -->

This pull request introduces support for the `light-dark()` CSS function in the Shiki library, allowing for dynamic theme switching between light and dark modes using CSS variables. It includes updates to the core logic, documentation, and tests to implement and validate this feature.

API-wise, it adds `useLightDarkFunction` option to enable this feature.

### Linked Issues

- https://github.com/shikijs/shiki/issues/1027

### Additional context

<!-- e.g. is there anything you'd like reviewers to focus on? -->

This is my first PR to the Shiki project.  I'm new to the codebase, so please let me know if I've missed anything or if there are any project conventions I should follow more closely.

I'm happy to make any changes as needed.  Thank you for your review and guidance!

netlify[bot]: ### <span aria-hidden="true">👷</span> Deploy Preview for *shiki-matsu* processing.


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | aad2271060bdd1c65eebbf9e828e788d5c45e575 |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/projects/shiki-matsu/deploys/683ea85ace2f9600084568f6 |
netlify[bot]: ### <span aria-hidden="true">👷</span> Deploy Preview for *shiki-next* processing.


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | aad2271060bdd1c65eebbf9e828e788d5c45e575 |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/projects/shiki-next/deploys/683ea85b7738d30008969589 |
antfu: I made some changes:

1. Unified the options with `defaultColor`
2. Require `light` and `dark` themes to be set, instead of relying on the implicit themes order

Patch:
diff --git a/docs/guide/dual-themes.md b/docs/guide/dual-themes.md
index 7c888529..14ca0ac1 100644
--- a/docs/guide/dual-themes.md
+++ b/docs/guide/dual-themes.md
@@ -147,3 +147,26 @@ With it, a token would be generated like:
 In that case, the generated HTML would have no style out of the box, you need to add your own CSS to control the colors.
 
 It's also possible to control the theme in CSS variables. For more, refer to the great research and examples by [@mayank99](https://github.com/mayank99) in [this issue #6](https://github.com/antfu/shikiji/issues/6).
+
+## `light-dark()` Function
+
+You can also use [`light-dark()`](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/light-dark) function to avoid manually maintaining the CSS variables.
+
+Set `defaultColor` to a special value `light-dark()` to use it. When using this, it requires both `light` and `dark` themes to be provided.
+
+```ts twoslash
+import { codeToHtml } from 'shiki'
+
+const code = await codeToHtml('console.log("hello")', {
+  lang: 'javascript',
+  themes: {
+    light: 'min-light',
+    dark: 'nord',
+  },
+  defaultColor: 'light-dark()', // [!code hl]
+})
+```
+
+:::info Compatibility Note
+The `light-dark()` function is relatively new and may not be supported in older browsers. [Can I use?](https://caniuse.com/?search=css-light-dark)
+:::
diff --git a/packages/core/src/highlight/code-to-hast.ts b/packages/core/src/highlight/code-to-hast.ts
index 5ace2996..e7ae839d 100644
--- a/packages/core/src/highlight/code-to-hast.ts
+++ b/packages/core/src/highlight/code-to-hast.ts
@@ -322,8 +322,8 @@ function mergeAdjacentStyledTokens(tokens: ThemedToken[][]): ThemedToken[][] {
       }
 
       const prevToken = newLine[newLine.length - 1]
-      const prevStyle = prevToken.htmlStyle || stringifyTokenStyle(getTokenStyleObject(prevToken))
-      const currentStyle = token.htmlStyle || stringifyTokenStyle(getTokenStyleObject(token))
+      const prevStyle = stringifyTokenStyle(prevToken.htmlStyle || getTokenStyleObject(prevToken))
+      const currentStyle = stringifyTokenStyle(token.htmlStyle || getTokenStyleObject(token))
       const isPrevDecorated = prevToken.fontStyle && (
         (prevToken.fontStyle & FontStyle.Underline)
         || (prevToken.fontStyle & FontStyle.Strikethrough)
diff --git a/packages/core/src/highlight/code-to-tokens.ts b/packages/core/src/highlight/code-to-tokens.ts
index d2855a5b..74d75d05 100644
--- a/packages/core/src/highlight/code-to-tokens.ts
+++ b/packages/core/src/highlight/code-to-tokens.ts
@@ -1,7 +1,8 @@
-import type { CodeToTokensOptions, GrammarState, ShikiInternal, ThemedToken, TokensResult } from '@shikijs/types'
+import type { CodeToTokensOptions, GrammarState, ShikiInternal, StringLiteralUnion, ThemedToken, ThemeRegistrationAny, TokensResult } from '@shikijs/types'
 import { ShikiError } from '@shikijs/types'
 import { getLastGrammarStateFromMap, setLastGrammarStateToMap } from '../textmate/grammar-state'
 import { applyColorReplacements, flatTokenVariants, resolveColorReplacements } from '../utils'
+import { DEFAULT_COLOR_LIGHT_DARK } from '../utils/_constants'
 import { codeToTokensBase } from './code-to-tokens-base'
 import { codeToTokensWithThemes } from './code-to-tokens-themes'
 
@@ -45,7 +46,7 @@ export function codeToTokens(
 
     grammarState = getLastGrammarStateFromMap(themeTokens)
 
-    if (defaultColor && !themes.find(t => t.color === defaultColor))
+    if (defaultColor && DEFAULT_COLOR_LIGHT_DARK !== defaultColor && !themes.find(t => t.color === defaultColor))
       throw new ShikiError(`\`themes\` option must contain the defaultColor key \`${defaultColor}\``)
 
     const themeRegs = themes.map(t => internal.getTheme(t.theme))
@@ -58,12 +59,9 @@ export function codeToTokens(
 
     const themeColorReplacements = themes.map(t => resolveColorReplacements(t.theme, options))
 
-    fg = themes.map((t, idx) => (idx === 0 && defaultColor
-      ? ''
-      : `${cssVariablePrefix + t.color}:`) + (applyColorReplacements(themeRegs[idx].fg, themeColorReplacements[idx]) || 'inherit')).join(';')
-    bg = themes.map((t, idx) => (idx === 0 && defaultColor
-      ? ''
-      : `${cssVariablePrefix + t.color}-bg:`) + (applyColorReplacements(themeRegs[idx].bg, themeColorReplacements[idx]) || 'inherit')).join(';')
+    fg = mapThemeColors(themes, themeRegs, themeColorReplacements, cssVariablePrefix, defaultColor, 'fg')
+    bg = mapThemeColors(themes, themeRegs, themeColorReplacements, cssVariablePrefix, defaultColor, 'bg')
+
     themeName = `shiki-themes ${themeRegs.map(t => t.name).join(' ')}`
     rootStyle = defaultColor ? undefined : [fg, bg].join(';')
   }
@@ -95,3 +93,33 @@ export function codeToTokens(
     grammarState,
   }
 }
+
+function mapThemeColors(
+  themes: Array<{ color: string, theme: ThemeRegistrationAny | StringLiteralUnion<string> }>,
+  themeRegs: any[],
+  themeColorReplacements: any[],
+  cssVariablePrefix: string,
+  defaultColor: false | StringLiteralUnion<'light' | 'dark'> | 'light-dark()' | undefined,
+  property: 'fg' | 'bg',
+): string {
+  return themes
+    .map((t, idx) => {
+      const value = applyColorReplacements(themeRegs[idx][property], themeColorReplacements[idx]) || 'inherit'
+      const cssVar = `${cssVariablePrefix + t.color}${property === 'bg' ? '-bg' : ''}:${value}`
+      if (idx === 0 && defaultColor) {
+        // light-dark()
+        if (defaultColor === DEFAULT_COLOR_LIGHT_DARK && themes.length > 1) {
+          const lightIndex = themes.findIndex(t => t.color === 'light')
+          const darkIndex = themes.findIndex(t => t.color === 'dark')
+          if (lightIndex === -1 || darkIndex === -1)
+            throw new ShikiError('When using `defaultColor: "light-dark()"`, you must provide both `light` and `dark` themes')
+          const lightValue = applyColorReplacements(themeRegs[lightIndex][property], themeColorReplacements[lightIndex]) || 'inherit'
+          const darkValue = applyColorReplacements(themeRegs[darkIndex][property], themeColorReplacements[darkIndex]) || 'inherit'
+          return `light-dark(${lightValue}, ${darkValue});${cssVar}`
+        }
+        return value
+      }
+      return cssVar
+    })
+    .join(';')
+}
diff --git a/packages/core/src/utils/_constants.ts b/packages/core/src/utils/_constants.ts
new file mode 100644
index 00000000..94e60501
--- /dev/null
+++ b/packages/core/src/utils/_constants.ts
@@ -0,0 +1,1 @@
+export const DEFAULT_COLOR_LIGHT_DARK = 'light-dark()'
diff --git a/packages/core/src/utils/tokens.ts b/packages/core/src/utils/tokens.ts
index 0b3c12fe..a8a34e90 100644
--- a/packages/core/src/utils/tokens.ts
+++ b/packages/core/src/utils/tokens.ts
@@ -1,5 +1,7 @@
 import type { ThemedToken, ThemedTokenWithVariants, TokenStyles } from '@shikijs/types'
+import { ShikiError } from '@shikijs/types'
 import { FontStyle } from '@shikijs/vscode-textmate'
+import { DEFAULT_COLOR_LIGHT_DARK } from './_constants'
 
 /**
  * Split a token into multiple tokens by given offsets.
@@ -72,7 +74,7 @@ export function flatTokenVariants(
   merged: ThemedTokenWithVariants,
   variantsOrder: string[],
   cssVariablePrefix: string,
-  defaultColor: string | boolean,
+  defaultColor: string | boolean | 'light-dark()',
 ): ThemedToken {
   const token: ThemedToken = {
     content: merged.content,
@@ -91,7 +93,23 @@ export function flatTokenVariants(
       const value = cur[key] || 'inherit'
 
       if (idx === 0 && defaultColor) {
-        mergedStyles[key] = value
+        // light-dark()
+        if (defaultColor === DEFAULT_COLOR_LIGHT_DARK && styles.length > 1) {
+          const lightIndex = variantsOrder.findIndex(t => t === 'light')
+          const darkIndex = variantsOrder.findIndex(t => t === 'dark')
+          if (lightIndex === -1 || darkIndex === -1)
+            throw new ShikiError('When using `defaultColor: "light-dark()"`, you must provide both `light` and `dark` themes')
+          const lightValue = styles[lightIndex][key] || 'inherit'
+          const darkValue = styles[darkIndex][key] || 'inherit'
+          mergedStyles[key] = `light-dark(${lightValue}, ${darkValue})`
+
+          const keyName = key === 'color' ? '' : key === 'background-color' ? '-bg' : `-${key}`
+          const varKey = cssVariablePrefix + variantsOrder[idx] + (key === 'color' ? '' : keyName)
+          mergedStyles[varKey] = value
+        }
+        else {
+          mergedStyles[key] = value
+        }
       }
       else {
         const keyName = key === 'color' ? '' : key === 'background-color' ? '-bg' : `-${key}`
diff --git a/packages/types/src/options.ts b/packages/types/src/options.ts
index 588329b8..d1e0e943 100644
--- a/packages/types/src/options.ts
+++ b/packages/types/src/options.ts
@@ -101,10 +101,15 @@ export interface CodeOptionsMultipleThemes<Themes extends string = string> {
    * <span style="--shiki-light:#{light};--shiki-dark:#{dark};--shiki-custom:#{custom};">code</span>
    * ```
    *
+   * When set to `light-dark()`, the default color will be rendered as `light-dark(#{light}, #{dark})`.
+   *
+   * ```html
+   * <span style="color:light-dark(#{light}, #{dark});--shiki-dark:#{dark};--shiki-custom:#{custom};">code</span>
+   * ```
    *
    * @default 'light'
    */
-  defaultColor?: StringLiteralUnion<'light' | 'dark'> | false
+  defaultColor?: StringLiteralUnion<'light' | 'dark'> | 'light-dark()' | false
 
   /**
    * Prefix of CSS variables used to store the color of the other theme.


Test patch:
diff --git a/packages/core/test/__snapshots__/tokens.test.ts.snap b/packages/core/test/__snapshots__/tokens.test.ts.snap
index 6e45fdc4..76451f61 100644
--- a/packages/core/test/__snapshots__/tokens.test.ts.snap
+++ b/packages/core/test/__snapshots__/tokens.test.ts.snap
@@ -1,5 +1,423 @@
 // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
 
+exports[`defaultColor light-dark() > basic > css-vars 1`] = `
+{
+  "bg": "--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212",
+  "fg": "--shiki-light:#393a34;--shiki-dark:#dbd7caee",
+  "grammarState": {
+    "lang": "javascript",
+    "scopes": [
+      "source.js",
+    ],
+    "theme": "vitesse-light",
+    "themes": [
+      "vitesse-light",
+      "vitesse-dark",
+    ],
+  },
+  "rootStyle": "--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212",
+  "themeName": "shiki-themes vitesse-light vitesse-dark",
+  "tokens": [
+    [
+      {
+        "content": "console",
+        "explanation": undefined,
+        "htmlStyle": {
+          "--shiki-dark": "#BD976A",
+          "--shiki-light": "#B07D48",
+        },
+        "offset": 0,
+      },
+      {
+        "content": ".",
+        "explanation": undefined,
+        "htmlStyle": {
+          "--shiki-dark": "#666666",
+          "--shiki-light": "#999999",
+        },
+        "offset": 7,
+      },
+      {
+        "content": "log",
+        "explanation": undefined,
+        "htmlStyle": {
+          "--shiki-dark": "#80A665",
+          "--shiki-light": "#59873A",
+        },
+        "offset": 8,
+      },
+      {
+        "content": "(",
+        "explanation": undefined,
+        "htmlStyle": {
+          "--shiki-dark": "#666666",
+          "--shiki-light": "#999999",
+        },
+        "offset": 11,
+      },
+      {
+        "content": """,
+        "explanation": undefined,
+        "htmlStyle": {
+          "--shiki-dark": "#C98A7D77",
+          "--shiki-light": "#B5695977",
+        },
+        "offset": 12,
+      },
+      {
+        "content": "hello",
+        "explanation": undefined,
+        "htmlStyle": {
+          "--shiki-dark": "#C98A7D",
+          "--shiki-light": "#B56959",
+        },
+        "offset": 13,
+      },
+      {
+        "content": """,
+        "explanation": undefined,
+        "htmlStyle": {
+          "--shiki-dark": "#C98A7D77",
+          "--shiki-light": "#B5695977",
+        },
+        "offset": 18,
+      },
+      {
+        "content": ")",
+        "explanation": undefined,
+        "htmlStyle": {
+          "--shiki-dark": "#666666",
+          "--shiki-light": "#999999",
+        },
+        "offset": 19,
+      },
+    ],
+  ],
+}
+`;
+
+exports[`defaultColor light-dark() > basic > light-dark() 1`] = `
+{
+  "bg": "light-dark(#ffffff, #121212);--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212",
+  "fg": "light-dark(#393a34, #dbd7caee);--shiki-light:#393a34;--shiki-dark:#dbd7caee",
+  "grammarState": {
+    "lang": "javascript",
+    "scopes": [
+      "source.js",
+    ],
+    "theme": "vitesse-light",
+    "themes": [
+      "vitesse-light",
+      "vitesse-dark",
+    ],
+  },
+  "rootStyle": undefined,
+  "themeName": "shiki-themes vitesse-light vitesse-dark",
+  "tokens": [
+    [
+      {
+        "content": "console",
+        "explanation": undefined,
+        "htmlStyle": {
+          "--shiki-dark": "#BD976A",
+          "--shiki-light": "#B07D48",
+          "color": "light-dark(#B07D48, #BD976A)",
+        },
+        "offset": 0,
+      },
+      {
+        "content": ".",
+        "explanation": undefined,
+        "htmlStyle": {
+          "--shiki-dark": "#666666",
+          "--shiki-light": "#999999",
+          "color": "light-dark(#999999, #666666)",
+        },
+        "offset": 7,
+      },
+      {
+        "content": "log",
+        "explanation": undefined,
+        "htmlStyle": {
+          "--shiki-dark": "#80A665",
+          "--shiki-light": "#59873A",
+          "color": "light-dark(#59873A, #80A665)",
+        },
+        "offset": 8,
+      },
+      {
+        "content": "(",
+        "explanation": undefined,
+        "htmlStyle": {
+          "--shiki-dark": "#666666",
+          "--shiki-light": "#999999",
+          "color": "light-dark(#999999, #666666)",
+        },
+        "offset": 11,
+      },
+      {
+        "content": """,
+        "explanation": undefined,
+        "htmlStyle": {
+          "--shiki-dark": "#C98A7D77",
+          "--shiki-light": "#B5695977",
+          "color": "light-dark(#B5695977, #C98A7D77)",
+        },
+        "offset": 12,
+      },
+      {
+        "content": "hello",
+        "explanation": undefined,
+        "htmlStyle": {
+          "--shiki-dark": "#C98A7D",
+          "--shiki-light": "#B56959",
+          "color": "light-dark(#B56959, #C98A7D)",
+        },
+        "offset": 13,
+      },
+      {
+        "content": """,
+        "explanation": undefined,
+        "htmlStyle": {
+          "--shiki-dark": "#C98A7D77",
+          "--shiki-light": "#B5695977",
+          "color": "light-dark(#B5695977, #C98A7D77)",
+        },
+        "offset": 18,
+      },
+      {
+        "content": ")",
+        "explanation": undefined,
+        "htmlStyle": {
+          "--shiki-dark": "#666666",
+          "--shiki-light": "#999999",
+          "color": "light-dark(#999999, #666666)",
+        },
+        "offset": 19,
+      },
+    ],
+  ],
+}
+`;
+
+exports[`defaultColor light-dark() > defaultColor light-dark() with multiple themes > css-vars 1`] = `
+{
+  "bg": "--shiki-dark-bg:#121212;--shiki-light-bg:#ffffff;--shiki-custom-bg:#24292e",
+  "fg": "--shiki-dark:#dbd7caee;--shiki-light:#393a34;--shiki-custom:#e1e4e8",
+  "grammarState": {
+    "lang": "javascript",
+    "scopes": [
+      "source.js",
+    ],
+    "theme": "vitesse-dark",
+    "themes": [
+      "vitesse-dark",
+      "vitesse-light",
+      "github-dark",
+    ],
+  },
+  "rootStyle": "--shiki-dark:#dbd7caee;--shiki-light:#393a34;--shiki-custom:#e1e4e8;--shiki-dark-bg:#121212;--shiki-light-bg:#ffffff;--shiki-custom-bg:#24292e",
+  "themeName": "shiki-themes vitesse-dark vitesse-light github-dark",
+  "tokens": [
+    [
+      {
+        "content": "console",
+        "explanation": undefined,
+        "htmlStyle": {
+          "--shiki-custom": "#E1E4E8",
+          "--shiki-dark": "#BD976A",
+          "--shiki-light": "#B07D48",
+        },
+        "offset": 0,
+      },
+      {
+        "content": ".",
+        "explanation": undefined,
+        "htmlStyle": {
+          "--shiki-custom": "#E1E4E8",
+          "--shiki-dark": "#666666",
+          "--shiki-light": "#999999",
+        },
+        "offset": 7,
+      },
+      {
+        "content": "log",
+        "explanation": undefined,
+        "htmlStyle": {
+          "--shiki-custom": "#B392F0",
+          "--shiki-dark": "#80A665",
+          "--shiki-light": "#59873A",
+        },
+        "offset": 8,
+      },
+      {
+        "content": "(",
+        "explanation": undefined,
+        "htmlStyle": {
+          "--shiki-custom": "#E1E4E8",
+          "--shiki-dark": "#666666",
+          "--shiki-light": "#999999",
+        },
+        "offset": 11,
+      },
+      {
+        "content": """,
+        "explanation": undefined,
+        "htmlStyle": {
+          "--shiki-custom": "#9ECBFF",
+          "--shiki-dark": "#C98A7D77",
+          "--shiki-light": "#B5695977",
+        },
+        "offset": 12,
+      },
+      {
+        "content": "hello",
+        "explanation": undefined,
+        "htmlStyle": {
+          "--shiki-custom": "#9ECBFF",
+          "--shiki-dark": "#C98A7D",
+          "--shiki-light": "#B56959",
+        },
+        "offset": 13,
+      },
+      {
+        "content": """,
+        "explanation": undefined,
+        "htmlStyle": {
+          "--shiki-custom": "#9ECBFF",
+          "--shiki-dark": "#C98A7D77",
+          "--shiki-light": "#B5695977",
+        },
+        "offset": 18,
+      },
+      {
+        "content": ")",
+        "explanation": undefined,
+        "htmlStyle": {
+          "--shiki-custom": "#E1E4E8",
+          "--shiki-dark": "#666666",
+          "--shiki-light": "#999999",
+        },
+        "offset": 19,
+      },
+    ],
+  ],
+}
+`;
+
+exports[`defaultColor light-dark() > defaultColor light-dark() with multiple themes > light-dark() 1`] = `
+{
+  "bg": "light-dark(#ffffff, #121212);--shiki-dark-bg:#121212;--shiki-light-bg:#ffffff;--shiki-custom-bg:#24292e",
+  "fg": "light-dark(#393a34, #dbd7caee);--shiki-dark:#dbd7caee;--shiki-light:#393a34;--shiki-custom:#e1e4e8",
+  "grammarState": {
+    "lang": "javascript",
+    "scopes": [
+      "source.js",
+    ],
+    "theme": "vitesse-dark",
+    "themes": [
+      "vitesse-dark",
+      "vitesse-light",
+      "github-dark",
+    ],
+  },
+  "rootStyle": undefined,
+  "themeName": "shiki-themes vitesse-dark vitesse-light github-dark",
+  "tokens": [
+    [
+      {
+        "content": "console",
+        "explanation": undefined,
+        "htmlStyle": {
+          "--shiki-custom": "#E1E4E8",
+          "--shiki-dark": "#BD976A",
+          "--shiki-light": "#B07D48",
+          "color": "light-dark(#B07D48, #BD976A)",
+        },
+        "offset": 0,
+      },
+      {
+        "content": ".",
+        "explanation": undefined,
+        "htmlStyle": {
+          "--shiki-custom": "#E1E4E8",
+          "--shiki-dark": "#666666",
+          "--shiki-light": "#999999",
+          "color": "light-dark(#999999, #666666)",
+        },
+        "offset": 7,
+      },
+      {
+        "content": "log",
+        "explanation": undefined,
+        "htmlStyle": {
+          "--shiki-custom": "#B392F0",
+          "--shiki-dark": "#80A665",
+          "--shiki-light": "#59873A",
+          "color": "light-dark(#59873A, #80A665)",
+        },
+        "offset": 8,
+      },
+      {
+        "content": "(",
+        "explanation": undefined,
+        "htmlStyle": {
+          "--shiki-custom": "#E1E4E8",
+          "--shiki-dark": "#666666",
+          "--shiki-light": "#999999",
+          "color": "light-dark(#999999, #666666)",
+        },
+        "offset": 11,
+      },
+      {
+        "content": """,
+        "explanation": undefined,
+        "htmlStyle": {
+          "--shiki-custom": "#9ECBFF",
+          "--shiki-dark": "#C98A7D77",
+          "--shiki-light": "#B5695977",
+          "color": "light-dark(#B5695977, #C98A7D77)",
+        },
+        "offset": 12,
+      },
+      {
+        "content": "hello",
+        "explanation": undefined,
+        "htmlStyle": {
+          "--shiki-custom": "#9ECBFF",
+          "--shiki-dark": "#C98A7D",
+          "--shiki-light": "#B56959",
+          "color": "light-dark(#B56959, #C98A7D)",
+        },
+        "offset": 13,
+      },
+      {
+        "content": """,
+        "explanation": undefined,
+        "htmlStyle": {
+          "--shiki-custom": "#9ECBFF",
+          "--shiki-dark": "#C98A7D77",
+          "--shiki-light": "#B5695977",
+          "color": "light-dark(#B5695977, #C98A7D77)",
+        },
+        "offset": 18,
+      },
+      {
+        "content": ")",
+        "explanation": undefined,
+        "htmlStyle": {
+          "--shiki-custom": "#E1E4E8",
+          "--shiki-dark": "#666666",
+          "--shiki-light": "#999999",
+          "color": "light-dark(#999999, #666666)",
+        },
+        "offset": 19,
+      },
+    ],
+  ],
+}
+`;
+
 exports[`includeExplanation > false 1`] = `
 [
   [
diff --git a/packages/core/test/hast.test.ts b/packages/core/test/hast.test.ts
index 2a802526..b16ba080 100644
--- a/packages/core/test/hast.test.ts
+++ b/packages/core/test/hast.test.ts
@@ -112,6 +112,38 @@ describe('merge same style', () => {
     expect(html).toMatchInlineSnapshot(`"<pre class="shiki min-light" style="background-color:#ffffff;color:#24292eff" tabindex="0"><code><span class="line"><span style="color:#D32F2F">name:</span><span style="color:#22863A"> CI</span></span></code></pre>"`)
   })
 
+  it('merges adjacent tokens with dual themes', async () => {
+    using shiki = await createHighlighter({
+      themes: ['min-light', 'min-dark'],
+      langs: ['yaml'],
+    })
+
+    const code = 'name: CI'
+    const html = await shiki.codeToHtml(code, {
+      lang: 'yaml',
+      themes: { dark: 'min-dark', light: 'min-light' },
+      mergeSameStyleTokens: true,
+    })
+
+    expect(html).toMatchInlineSnapshot(`"<pre class="shiki shiki-themes min-light min-dark" style="background-color:#ffffff;--shiki-dark-bg:#1f1f1f;color:#24292eff;--shiki-dark:#b392f0" tabindex="0"><code><span class="line"><span style="color:#D32F2F;--shiki-dark:#F8F8F8">name</span><span style="color:#D32F2F;--shiki-dark:#F97583">:</span><span style="color:#22863A;--shiki-dark:#FFAB70"> CI</span></span></code></pre>"`)
+  })
+
+  it('merges adjacent tokens with the same dual themes', async () => {
+    using shiki = await createHighlighter({
+      themes: ['min-light'],
+      langs: ['yaml'],
+    })
+
+    const code = 'name: CI'
+    const html = await shiki.codeToHtml(code, {
+      lang: 'yaml',
+      themes: { dark: 'min-light', light: 'min-light' },
+      mergeSameStyleTokens: true,
+    })
+
+    expect(html).toMatchInlineSnapshot(`"<pre class="shiki shiki-themes min-light min-light" style="background-color:#ffffff;--shiki-dark-bg:#ffffff;color:#24292eff;--shiki-dark:#24292eff" tabindex="0"><code><span class="line"><span style="color:#D32F2F;--shiki-dark:#D32F2F">name:</span><span style="color:#22863A;--shiki-dark:#22863A"> CI</span></span></code></pre>"`)
+  })
+
   it('does not merge tokens with decorations', async () => {
     using shiki = await createHighlighter({
       themes: ['min-light'],
diff --git a/packages/core/test/tokens.test.ts b/packages/core/test/tokens.test.ts
index 3e7ebec6..85f6b0fe 100644
--- a/packages/core/test/tokens.test.ts
+++ b/packages/core/test/tokens.test.ts
@@ -1,6 +1,6 @@
 import { createJavaScriptRegexEngine } from 'shiki'
-import { expect, it } from 'vitest'
-import { codeToTokensBase, createShikiInternal } from '../src'
+import { describe, expect, it } from 'vitest'
+import { codeToTokens, codeToTokensBase, createShikiInternal } from '../src'
 
 it('includeExplanation', async () => {
   using engine = await createShikiInternal({
@@ -22,3 +22,117 @@ it('includeExplanation', async () => {
   expect(caseTrue).toMatchSnapshot('true')
   expect(caseScopeName).toMatchSnapshot('scopeName')
 })
+
+describe('defaultColor light-dark()', () => {
+  it('basic', async () => {
+    using engine = await createShikiInternal({
+      themes: [
+        import('@shikijs/themes/vitesse-light'),
+        import('@shikijs/themes/vitesse-dark'),
+      ],
+      langs: [
+        import('@shikijs/langs/javascript'),
+      ],
+      engine: createJavaScriptRegexEngine(),
+    })
+
+    const code = 'console.log("hello")'
+    const caseCssVars = codeToTokens(engine, code, {
+      lang: 'js',
+      themes: { light: 'vitesse-light', dark: 'vitesse-dark' },
+      defaultColor: false,
+    })
+    const caseLightDark = codeToTokens(engine, code, {
+      lang: 'js',
+      themes: { light: 'vitesse-light', dark: 'vitesse-dark' },
+      defaultColor: 'light-dark()',
+    })
+
+    expect(caseCssVars).toMatchSnapshot('css-vars')
+    expect(caseLightDark).toMatchSnapshot('light-dark()')
+  })
+
+  it('defaultColor light-dark() with multiple themes', async () => {
+    using engine = await createShikiInternal({
+      themes: [
+        import('@shikijs/themes/vitesse-light'),
+        import('@shikijs/themes/vitesse-dark'),
+        import('@shikijs/themes/github-dark'),
+      ],
+      langs: [
+        import('@shikijs/langs/javascript'),
+      ],
+      engine: createJavaScriptRegexEngine(),
+    })
+
+    const code = 'console.log("hello")'
+    const caseCssVars = codeToTokens(engine, code, {
+      lang: 'js',
+      themes: {
+        dark: 'vitesse-dark',
+        light: 'vitesse-light',
+        custom: 'github-dark',
+      },
+      defaultColor: false,
+    })
+    const caseLightDark = codeToTokens(engine, code, {
+      lang: 'js',
+      themes: {
+        dark: 'vitesse-dark',
+        light: 'vitesse-light',
+        custom: 'github-dark',
+      },
+      defaultColor: 'light-dark()',
+    })
+
+    expect(caseCssVars).toMatchSnapshot('css-vars')
+    expect(caseLightDark).toMatchSnapshot('light-dark()')
+  })
+
+  it('should throw when no light or dark theme is provided', async () => {
+    using engine = await createShikiInternal({
+      themes: [
+        import('@shikijs/themes/vitesse-light'),
+        import('@shikijs/themes/vitesse-dark'),
+        import('@shikijs/themes/github-dark'),
+      ],
+      langs: [
+        import('@shikijs/langs/javascript'),
+      ],
+      engine: createJavaScriptRegexEngine(),
+    })
+
+    const code = 'console.log("hello")'
+
+    expect(() => {
+      codeToTokens(engine, code, {
+        lang: 'js',
+        themes: {
+          dark2: 'vitesse-dark',
+          light1: 'vitesse-light',
+        },
+        defaultColor: 'light-dark()',
+      })
+    })
+      .toThrowErrorMatchingInlineSnapshot(`[ShikiError: When using \`defaultColor: "light-dark()"\`, you must provide both \`light\` and \`dark\` themes]`)
+
+    expect(() => {
+      codeToTokens(engine, code, {
+        lang: 'js',
+        themes: {
+          dark: 'vitesse-dark',
+          light1: 'vitesse-light',
+        },
+        defaultColor: 'light-dark()',
+      })
+    })
+      .toThrowErrorMatchingInlineSnapshot(`[ShikiError: When using \`defaultColor: "light-dark()"\`, you must provide both \`light\` and \`dark\` themes]`)
+
+    // not throw when only one theme is provided
+    codeToTokens(engine, code, {
+      lang: 'js',
+      theme: 'vitesse-dark',
+      defaultColor: 'light-dark()',
+    })
+  })
+})