diff --git a/packages/docs/src/pages/en/getting-started/upgrade-guide.md b/packages/docs/src/pages/en/getting-started/upgrade-guide.md
index b9f5a13974a..c9222a260e4 100644
--- a/packages/docs/src/pages/en/getting-started/upgrade-guide.md
+++ b/packages/docs/src/pages/en/getting-started/upgrade-guide.md
@@ -21,3 +21,30 @@ This page contains a detailed list of breaking changes and the steps required to
- Returned Refs are now readonly.
- Breakpoints are matched with `window.matchMedia` instead of `window.innerWidth`. This may result in slightly different values at zoom levels other than 100%.
+
+## Theme
+
+The theme system now uses `@vuetify/v0` under the hood. The consumer API (`useTheme`, `VThemeProvider`) is unchanged for most users.
+
+### ThemeInstance
+
+Several properties have been removed from the `ThemeInstance` type:
+
+- `styles` — CSS injection is now handled internally by the theme adapter
+- `isDisabled` — themes are always enabled
+- `isSystem` — check `name.value === 'system'` directly
+
+### Runtime theme changes
+
+Assigning new themes directly to `themes.value` is replaced by `register()`:
+
+```diff
+- theme.themes.value.custom = { dark: true, colors: { primary: '#ff5722' } }
++ theme.register({ id: 'custom', dark: true, colors: { primary: '#ff5722' } })
+```
+
+Mutating existing theme colors continues to work:
+
+```ts
+theme.themes.value.light.colors.primary = '#ff0000'
+```
diff --git a/packages/vuetify/src/components/VThemeProvider/VThemeProvider.tsx b/packages/vuetify/src/components/VThemeProvider/VThemeProvider.tsx
index 66ca0cd7974..c6c396bbf0e 100644
--- a/packages/vuetify/src/components/VThemeProvider/VThemeProvider.tsx
+++ b/packages/vuetify/src/components/VThemeProvider/VThemeProvider.tsx
@@ -1,6 +1,9 @@
// Styles
import './VThemeProvider.sass'
+// Components
+import { Theme } from '@vuetify/v0/components'
+
// Composables
import { makeComponentProps } from '@/composables/component'
import { makeTagProps } from '@/composables/tag'
@@ -26,19 +29,27 @@ export const VThemeProvider = genericComponent()({
const { themeClasses } = provideTheme(props)
return () => {
- if (!props.withBackground) return slots.default?.()
+ if (!props.withBackground) {
+ return (
+
+ { slots.default?.() }
+
+ )
+ }
return (
-
- { slots.default?.() }
-
+
+
+ { slots.default?.() }
+
+
)
}
},
diff --git a/packages/vuetify/src/composables/theme.ts b/packages/vuetify/src/composables/theme.ts
deleted file mode 100644
index 4e764dd15bb..00000000000
--- a/packages/vuetify/src/composables/theme.ts
+++ /dev/null
@@ -1,592 +0,0 @@
-// Utilities
-import {
- computed,
- getCurrentScope,
- inject,
- onScopeDispose,
- provide,
- ref,
- shallowRef,
- toRef,
- watch,
- watchEffect,
-} from 'vue'
-import {
- consoleWarn,
- createRange,
- darken,
- deprecate,
- getCurrentInstance,
- getLuma,
- hasLightForeground,
- IN_BROWSER,
- lighten,
- mergeDeep,
- parseColor,
- propsFactory,
- RGBtoHex,
- SUPPORTS_MATCH_MEDIA,
-} from '@/util'
-
-// Types
-import type { VueHeadClient } from '@unhead/vue/client'
-import type { HeadClient } from '@vueuse/head'
-import type { App, DeepReadonly, InjectionKey, Ref } from 'vue'
-import type { Color } from '@/util'
-
-type DeepPartial = T extends object ? { [P in keyof T]?: DeepPartial } : T
-
-export type ThemeOptions = false | {
- cspNonce?: string
- defaultTheme?: 'light' | 'dark' | 'system' | string & {}
- variations?: false | VariationsOptions
- themes?: Record
- stylesheetId?: string
- scope?: string
- utilities?: boolean
-}
-export type ThemeDefinition = DeepPartial
-
-interface InternalThemeOptions {
- cspNonce?: string
- isDisabled: boolean
- defaultTheme: 'light' | 'dark' | 'system' | string & {}
- prefix: string
- variations: false | VariationsOptions
- themes: Record
- stylesheetId: string
- scope?: string
- scoped: boolean
- utilities: boolean
-}
-
-interface VariationsOptions {
- colors: string[]
- lighten: number
- darken: number
-}
-
-interface InternalThemeDefinition {
- dark: boolean
- colors: Colors
- variables: Record
-}
-
-export interface Colors extends BaseColors, OnColors {
- [key: string]: Color
-}
-
-interface BaseColors {
- background: Color
- surface: Color
- primary: Color
- secondary: Color
- success: Color
- warning: Color
- error: Color
- info: Color
-}
-
-interface OnColors {
- 'on-background': Color
- 'on-surface': Color
- 'on-primary': Color
- 'on-secondary': Color
- 'on-success': Color
- 'on-warning': Color
- 'on-error': Color
- 'on-info': Color
-}
-
-export interface ThemeInstance {
- change: (themeName: string) => void
- cycle: (themeArray?: string[]) => void
- toggle: (themeArray?: [string, string]) => void
-
- readonly isDisabled: boolean
- readonly isSystem: Readonly[>
- readonly themes: Ref>
-
- readonly name: Readonly][>
- readonly current: DeepReadonly][>
- readonly computedThemes: DeepReadonly][>>
- readonly prefix: string
-
- readonly themeClasses: Readonly][>
- readonly styles: Readonly][>
-
- readonly global: {
- readonly name: Ref
- readonly current: DeepReadonly][>
- }
-}
-
-export const ThemeSymbol: InjectionKey = Symbol.for('vuetify:theme')
-
-export const makeThemeProps = propsFactory({
- theme: String,
-}, 'theme')
-
-function genDefaults () {
- return {
- defaultTheme: 'system',
- prefix: 'v-',
- variations: { colors: [], lighten: 0, darken: 0 },
- themes: {
- light: {
- dark: false,
- colors: {
- background: '#FFFFFF',
- surface: '#FFFFFF',
- 'surface-bright': '#FFFFFF',
- 'surface-light': '#EEEEEE',
- 'surface-variant': '#424242',
- 'on-surface-variant': '#EEEEEE',
- primary: '#1867C0',
- 'primary-darken-1': '#1F5592',
- secondary: '#48A9A6',
- 'secondary-darken-1': '#018786',
- error: '#B00020',
- info: '#2196F3',
- success: '#4CAF50',
- warning: '#FB8C00',
- },
- variables: {
- 'border-color': '#000000',
- 'border-opacity': 0.12,
- 'shadow-color': '#000000',
- 'high-emphasis-opacity': 0.87,
- 'medium-emphasis-opacity': 0.60,
- 'disabled-opacity': 0.38,
- 'idle-opacity': 0.04,
- 'hover-opacity': 0.04,
- 'focus-opacity': 0.12,
- 'selected-opacity': 0.08,
- 'activated-opacity': 0.12,
- 'pressed-opacity': 0.12,
- 'dragged-opacity': 0.08,
- 'theme-kbd': '#EEEEEE',
- 'theme-on-kbd': '#000000',
- 'theme-code': '#F5F5F5',
- 'theme-on-code': '#000000',
- 'theme-on-dark': '#FFF',
- 'theme-on-light': '#000',
- 'elevation-overlay-color': 'black',
- 'elevation-overlay-opacity-step': '2%',
- },
- },
- dark: {
- dark: true,
- colors: {
- background: '#121212',
- surface: '#212121',
- 'surface-bright': '#ccbfd6',
- 'surface-light': '#424242',
- 'surface-variant': '#c8c8c8',
- 'on-surface-variant': '#000000',
- primary: '#2196F3',
- 'primary-darken-1': '#277CC1',
- secondary: '#54B6B2',
- 'secondary-darken-1': '#48A9A6',
- error: '#CF6679',
- info: '#2196F3',
- success: '#4CAF50',
- warning: '#FB8C00',
- },
- variables: {
- 'border-color': '#FFFFFF',
- 'border-opacity': 0.12,
- 'shadow-color': '#000000',
- 'high-emphasis-opacity': 1,
- 'medium-emphasis-opacity': 0.70,
- 'disabled-opacity': 0.50,
- 'idle-opacity': 0.10,
- 'hover-opacity': 0.04,
- 'focus-opacity': 0.12,
- 'selected-opacity': 0.08,
- 'activated-opacity': 0.12,
- 'pressed-opacity': 0.16,
- 'dragged-opacity': 0.08,
- 'theme-kbd': '#424242',
- 'theme-on-kbd': '#FFFFFF',
- 'theme-code': '#343434',
- 'theme-on-code': '#CCCCCC',
- 'theme-on-dark': '#FFF',
- 'theme-on-light': '#000',
- 'elevation-overlay-color': 'white',
- 'elevation-overlay-opacity-step': '2%',
- },
- },
- },
- stylesheetId: 'vuetify-theme-stylesheet',
- scoped: false,
- utilities: true,
- }
-}
-
-function parseThemeOptions (options: ThemeOptions = genDefaults()): InternalThemeOptions {
- const defaults = genDefaults()
-
- if (!options) return { ...defaults, isDisabled: true } as any
-
- return mergeDeep(defaults, options) as InternalThemeOptions
-}
-
-function createCssClass (lines: string[], selector: string, content: string[], scope?: string) {
- lines.push(
- `${getScopedSelector(selector, scope)} {\n`,
- ...content.map(line => ` ${line};\n`),
- '}\n',
- )
-}
-
-function genCssVariables (theme: InternalThemeDefinition, prefix: string) {
- const lightOverlay = theme.dark ? 2 : 1
- const darkOverlay = theme.dark ? 1 : 2
-
- const variables: string[] = []
- for (const [key, value] of Object.entries(theme.colors)) {
- const rgb = parseColor(value)
- variables.push(`--${prefix}theme-${key}: ${rgb.r},${rgb.g},${rgb.b}` + (rgb.a == null ? '' : `,${rgb.a}`))
- if (!key.startsWith('on-')) {
- variables.push(`--${prefix}theme-${key}-overlay-multiplier: ${getLuma(value) > 0.18 ? lightOverlay : darkOverlay}`)
- }
- }
-
- for (const [key, value] of Object.entries(theme.variables)) {
- const color = typeof value === 'string' && value.startsWith('#') ? parseColor(value) : undefined
- const rgb = color ? `${color.r}, ${color.g}, ${color.b}` : undefined
- variables.push(`--${prefix}${key}: ${rgb ?? value}`)
- }
-
- return variables
-}
-
-function genVariation (name: string, color: Color, variations: VariationsOptions | false) {
- const object: Record = {}
- if (variations) {
- for (const variation of (['lighten', 'darken'] as const)) {
- const fn = variation === 'lighten' ? lighten : darken
- for (const amount of createRange(variations[variation], 1)) {
- object[`${name}-${variation}-${amount}`] = RGBtoHex(fn(parseColor(color), amount))
- }
- }
- }
- return object
-}
-
-function genVariations (colors: InternalThemeDefinition['colors'], variations: VariationsOptions | false) {
- if (!variations) return {}
-
- let variationColors = {}
- for (const name of variations.colors) {
- const color = colors[name]
-
- if (!color) continue
-
- variationColors = {
- ...variationColors,
- ...genVariation(name, color, variations),
- }
- }
- return variationColors
-}
-
-function genOnColors (colors: InternalThemeDefinition['colors'], variables: InternalThemeDefinition['variables']) {
- const onColors = {} as InternalThemeDefinition['colors']
-
- for (const color of Object.keys(colors)) {
- if (color.startsWith('on-') || colors[`on-${color}`]) continue
-
- const onColor = `on-${color}` as keyof OnColors
- const colorVal = parseColor(colors[color])
-
- onColors[onColor] = hasLightForeground(colorVal)
- ? variables['theme-on-dark']
- : variables['theme-on-light']
- }
-
- return onColors
-}
-
-function getScopedSelector (selector: string, scope?: string) {
- if (!scope) return selector
-
- const scopeSelector = `:where(${scope})`
-
- return selector === ':root' ? scopeSelector : `${scopeSelector} ${selector}`
-}
-
-function upsertStyles (id: string, cspNonce: string | undefined, styles: string) {
- const styleEl = getOrCreateStyleElement(id, cspNonce)
-
- if (!styleEl) return
-
- styleEl.innerHTML = styles
-}
-
-function getOrCreateStyleElement (id: string, cspNonce?: string) {
- if (!IN_BROWSER) return null
-
- let style = document.getElementById(id) as HTMLStyleElement | null
-
- if (!style) {
- style = document.createElement('style')
- style.id = id
- style.type = 'text/css'
-
- if (cspNonce) style.setAttribute('nonce', cspNonce)
-
- document.head.appendChild(style)
- }
-
- return style
-}
-
-// Composables
-export function createTheme (options?: ThemeOptions): ThemeInstance & { install: (app: App) => void } {
- const parsedOptions = parseThemeOptions(options)
- const _name = shallowRef(parsedOptions.defaultTheme)
- const themes = ref(parsedOptions.themes)
- const systemName = shallowRef('light')
-
- const name = computed({
- get () {
- return _name.value === 'system' ? systemName.value : _name.value
- },
- set (val: string) {
- _name.value = val
- },
- })
-
- const computedThemes = computed(() => {
- const acc: Record = {}
- for (const [name, original] of Object.entries(themes.value)) {
- const defaultTheme = original.dark || name === 'dark'
- ? themes.value.dark
- : themes.value.light
-
- const merged = mergeDeep(defaultTheme, original) as InternalThemeDefinition
-
- const colors = {
- ...merged.colors,
- ...genVariations(merged.colors, parsedOptions.variations),
- }
-
- acc[name] = {
- ...merged,
- colors: {
- ...colors,
- ...genOnColors(colors, merged.variables),
- },
- }
- }
- return acc
- })
-
- const current = toRef(() => computedThemes.value[name.value])
-
- const isSystem = toRef(() => _name.value === 'system')
-
- const styles = computed(() => {
- const lines: string[] = []
- const scoped = parsedOptions.scoped ? parsedOptions.prefix : ''
-
- lines.push('@layer theme-base {\n')
-
- if (current.value?.dark) {
- createCssClass(lines, ':root', ['color-scheme: dark'], parsedOptions.scope)
- }
-
- createCssClass(lines, ':root', genCssVariables(current.value, parsedOptions.prefix), parsedOptions.scope)
-
- for (const [themeName, theme] of Object.entries(computedThemes.value)) {
- createCssClass(lines, `.${parsedOptions.prefix}theme--${themeName}`, [
- `color-scheme: ${theme.dark ? 'dark' : 'normal'}`,
- ...genCssVariables(theme, parsedOptions.prefix),
- ], parsedOptions.scope)
- }
-
- lines.push('}\n')
-
- if (parsedOptions.utilities) {
- const bgLines: string[] = []
- const fgLines: string[] = []
-
- const colors = new Set(Object.values(computedThemes.value).flatMap(theme => Object.keys(theme.colors)))
- for (const key of colors) {
- if (key.startsWith('on-')) {
- createCssClass(fgLines, `.${key}`, [`color: rgb(var(--${parsedOptions.prefix}theme-${key}))`], parsedOptions.scope)
- } else {
- createCssClass(bgLines, `.${scoped}bg-${key}`, [
- `--${parsedOptions.prefix}theme-overlay-multiplier: var(--${parsedOptions.prefix}theme-${key}-overlay-multiplier)`,
- `background-color: rgb(var(--${parsedOptions.prefix}theme-${key}))`,
- `color: rgb(var(--${parsedOptions.prefix}theme-on-${key}))`,
- ], parsedOptions.scope)
- createCssClass(fgLines, `.${scoped}text-${key}`, [`color: rgb(var(--${parsedOptions.prefix}theme-${key}))`], parsedOptions.scope)
- createCssClass(fgLines, `.${scoped}border-${key}`, [`--${parsedOptions.prefix}border-color: var(--${parsedOptions.prefix}theme-${key})`], parsedOptions.scope)
- }
- }
-
- lines.push(
- '@layer theme-background {\n',
- ...bgLines.map(v => ` ${v}`),
- '}\n',
- '@layer theme-foreground {\n',
- ...fgLines.map(v => ` ${v}`),
- '}\n',
- )
- }
-
- return '@layer vuetify-utilities {\n' + lines.map(v => ` ${v}`).join('') + '\n}'
- })
-
- const themeClasses = toRef(() => parsedOptions.isDisabled ? undefined : `${parsedOptions.prefix}theme--${name.value}`)
- const themeNames = toRef(() => Object.keys(computedThemes.value))
-
- if (SUPPORTS_MATCH_MEDIA) {
- const media = window.matchMedia('(prefers-color-scheme: dark)')
-
- function updateSystemName () {
- systemName.value = media.matches ? 'dark' : 'light'
- }
-
- updateSystemName()
-
- media.addEventListener('change', updateSystemName, { passive: true })
-
- if (getCurrentScope()) {
- onScopeDispose(() => {
- media.removeEventListener('change', updateSystemName)
- })
- }
- }
-
- function install (app: App) {
- if (parsedOptions.isDisabled) return
-
- const head = app._context.provides.usehead as HeadClient & VueHeadClient | undefined
- if (head) {
- function getHead () {
- return {
- style: [{
- textContent: styles.value,
- id: parsedOptions.stylesheetId,
- nonce: parsedOptions.cspNonce || false as never,
- tagPosition: 'bodyOpen' as const,
- }],
- }
- }
-
- if (head.push) {
- const entry = head.push(getHead)
- if (IN_BROWSER) {
- watch(styles, () => { entry.patch(getHead) })
- }
- } else {
- if (IN_BROWSER) {
- head.addHeadObjs(toRef(getHead))
- watchEffect(() => head.updateDOM())
- } else {
- head.addHeadObjs(getHead())
- }
- }
- } else {
- if (IN_BROWSER) {
- watch(styles, updateStyles, { immediate: true })
- } else {
- updateStyles()
- }
-
- function updateStyles () {
- upsertStyles(parsedOptions.stylesheetId, parsedOptions.cspNonce, styles.value)
- }
- }
- }
-
- function change (themeName: string) {
- if (themeName !== 'system' && !themeNames.value.includes(themeName)) {
- consoleWarn(`Theme "${themeName}" not found on the Vuetify theme instance`)
- return
- }
-
- name.value = themeName
- }
-
- function cycle (themeArray: string[] = themeNames.value) {
- const currentIndex = themeArray.indexOf(name.value)
- const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % themeArray.length
-
- change(themeArray[nextIndex])
- }
-
- function toggle (themeArray: [string, string] = ['light', 'dark']) {
- cycle(themeArray)
- }
-
- const globalName = new Proxy(name, {
- get (target, prop) {
- return Reflect.get(target, prop)
- },
- set (target, prop, val) {
- if (prop === 'value') {
- deprecate(`theme.global.name.value = ${val}`, `theme.change('${val}')`)
- }
- return Reflect.set(target, prop, val)
- },
- })
-
- return {
- install,
- change,
- cycle,
- toggle,
- isDisabled: parsedOptions.isDisabled,
- isSystem,
- name,
- themes,
- current,
- computedThemes,
- prefix: parsedOptions.prefix,
- themeClasses,
- styles,
- global: {
- name: globalName,
- current,
- },
- }
-}
-
-export function provideTheme (props: { theme?: string }) {
- getCurrentInstance('provideTheme')
-
- const theme = inject(ThemeSymbol, null)
-
- if (!theme) throw new Error('Could not find Vuetify theme injection')
-
- const name = toRef(() => props.theme ?? theme.name.value)
- const current = toRef(() => theme.themes.value[name.value])
-
- const themeClasses = toRef(() => theme.isDisabled ? undefined : `${theme.prefix}theme--${name.value}`)
-
- const newTheme: ThemeInstance = {
- ...theme,
- name,
- current,
- themeClasses,
- }
-
- provide(ThemeSymbol, newTheme)
-
- return newTheme
-}
-
-export function useTheme () {
- getCurrentInstance('useTheme')
-
- const theme = inject(ThemeSymbol, null)
-
- if (!theme) throw new Error('Could not find Vuetify theme injection')
-
- return theme
-}
diff --git a/packages/vuetify/src/composables/theme/adapter.ts b/packages/vuetify/src/composables/theme/adapter.ts
new file mode 100644
index 00000000000..18ebe4ac7e0
--- /dev/null
+++ b/packages/vuetify/src/composables/theme/adapter.ts
@@ -0,0 +1,218 @@
+// Composables
+import { genCssVariables } from './colors'
+
+// Utilities
+import { ThemeAdapter } from '@vuetify/v0/theme/adapters'
+import { watch } from 'vue'
+import { IN_BROWSER } from '@/util'
+
+// Types
+import type { App, ComputedRef, Ref } from 'vue'
+import type { InternalThemeDefinition } from './colors'
+
+interface AdapterSetupContext {
+ colors: ComputedRef>>
+ selectedId: Ref
+ isDark: Readonly][>
+}
+
+export interface VuetifyThemeAdapterOptions {
+ cspNonce?: string
+ scope?: string
+ stylesheetId?: string
+ prefix?: string
+ utilities?: boolean
+}
+
+interface HeadEntry {
+ patch (input: () => Record): void
+}
+
+interface HeadClient {
+ push (input: Record): HeadEntry
+}
+
+/**
+ * Theme adapter that generates Vuetify-specific CSS with layered
+ * utility classes and RGB-decomposed custom properties.
+ *
+ * Receives fully-resolved themes (including on-* colors, variations,
+ * and variables) from the theme composable and formats them as CSS.
+ */
+export class VuetifyThemeAdapter extends ThemeAdapter {
+ cspNonce?: string
+ scope?: string
+ utilities: boolean
+
+ private sheet?: CSSStyleSheet
+ private themes: Record = {}
+
+ constructor (options: VuetifyThemeAdapterOptions = {}) {
+ super(options.prefix ?? 'v-')
+ this.cspNonce = options.cspNonce
+ this.scope = options.scope
+ this.utilities = options.utilities ?? true
+ this.stylesheetId = options.stylesheetId ?? 'vuetify-theme-stylesheet'
+
+ // Vuetify always outputs RGB-decomposed values
+ this.rgb = true
+ }
+
+ /**
+ * Provide the full theme definitions (with dark, colors, variables)
+ * so that generate() can produce complete Vuetify CSS.
+ */
+ setThemes (themes: Record) {
+ this.themes = themes
+ }
+
+ override generate (
+ _colors: Record>,
+ isDark?: boolean,
+ ): string {
+ const lines: string[] = []
+
+ lines.push('@layer theme-base {\n')
+
+ if (isDark) {
+ this.pushCssClass(lines, ':root', ['color-scheme: dark'])
+ }
+
+ // Root CSS variables for current theme
+ const currentThemeName = isDark ? 'dark' : 'light'
+ const currentTheme = this.themes[currentThemeName]
+ if (currentTheme) {
+ this.pushCssClass(lines, ':root', genCssVariables(currentTheme, this.prefix))
+ }
+
+ // Per-theme classes
+ for (const [themeName, theme] of Object.entries(this.themes)) {
+ this.pushCssClass(lines, `.${this.prefix}theme--${themeName}`, [
+ `color-scheme: ${theme.dark ? 'dark' : 'normal'}`,
+ ...genCssVariables(theme, this.prefix),
+ ])
+ }
+
+ lines.push('}\n')
+
+ if (this.utilities) {
+ const bgLines: string[] = []
+ const fgLines: string[] = []
+
+ const scoped = this.scope ? this.prefix : ''
+ const colors = new Set(
+ Object.values(this.themes).flatMap(theme => Object.keys(theme.colors))
+ )
+
+ for (const key of colors) {
+ if (key.startsWith('on-')) {
+ this.pushCssClass(fgLines, `.${key}`, [
+ `color: rgb(var(--${this.prefix}theme-${key}))`,
+ ])
+ } else {
+ this.pushCssClass(bgLines, `.${scoped}bg-${key}`, [
+ `--${this.prefix}theme-overlay-multiplier: var(--${this.prefix}theme-${key}-overlay-multiplier)`,
+ `background-color: rgb(var(--${this.prefix}theme-${key}))`,
+ `color: rgb(var(--${this.prefix}theme-on-${key}))`,
+ ])
+ this.pushCssClass(fgLines, `.${scoped}text-${key}`, [
+ `color: rgb(var(--${this.prefix}theme-${key}))`,
+ ])
+ this.pushCssClass(fgLines, `.${scoped}border-${key}`, [
+ `--${this.prefix}border-color: var(--${this.prefix}theme-${key})`,
+ ])
+ }
+ }
+
+ lines.push(
+ '@layer theme-background {\n',
+ ...bgLines.map(v => ` ${v}`),
+ '}\n',
+ '@layer theme-foreground {\n',
+ ...fgLines.map(v => ` ${v}`),
+ '}\n',
+ )
+ }
+
+ return '@layer vuetify-utilities {\n' + lines.map(v => ` ${v}`).join('') + '\n}'
+ }
+
+ // @ts-expect-error Vue types mismatch between v0 and vuetify packages
+ setup (
+ app: App,
+ context: T,
+ _target?: string | HTMLElement | null,
+ ): void {
+ const head = app._context?.provides?.usehead as HeadClient | undefined
+
+ if (head) {
+ function getHead (adapter: VuetifyThemeAdapter) {
+ return {
+ style: [{
+ textContent: adapter.generate(context.colors.value, context.isDark.value),
+ id: adapter.stylesheetId,
+ nonce: adapter.cspNonce || false as never,
+ tagPosition: 'bodyOpen' as const,
+ }],
+ }
+ }
+
+ const entry = head.push(getHead(this))
+
+ if (IN_BROWSER) {
+ watch([context.colors, context.isDark], () => {
+ entry.patch(() => getHead(this))
+ })
+ }
+ } else if (IN_BROWSER) {
+ this.update(context.colors.value, context.isDark.value)
+
+ watch([context.colors, context.isDark], ([colors, isDark]) => {
+ this.update(
+ colors as Record>,
+ isDark as boolean,
+ )
+ })
+ } else {
+ // SSR without head — generate but cannot inject
+ this.generate(context.colors.value, context.isDark.value)
+ }
+ }
+
+ update (
+ colors: Record>,
+ isDark?: boolean,
+ ): void {
+ if (!IN_BROWSER) return
+
+ this.upsert(this.generate(colors, isDark))
+ }
+
+ private upsert (styles: string): void {
+ if (!IN_BROWSER) return
+
+ if (!this.sheet) {
+ this.sheet = new CSSStyleSheet()
+ document.adoptedStyleSheets = [...document.adoptedStyleSheets, this.sheet]
+ }
+
+ this.sheet.replaceSync(styles)
+ }
+
+ private pushCssClass (lines: string[], selector: string, content: string[]): void {
+ const scoped = this.getScopedSelector(selector)
+ lines.push(
+ `${scoped} {\n`,
+ ...content.map(line => ` ${line};\n`),
+ '}\n',
+ )
+ }
+
+ private getScopedSelector (selector: string): string {
+ if (!this.scope) return selector
+
+ const scopeSelector = `:where(${this.scope})`
+
+ return selector === ':root' ? scopeSelector : `${scopeSelector} ${selector}`
+ }
+}
diff --git a/packages/vuetify/src/composables/theme/colors.ts b/packages/vuetify/src/composables/theme/colors.ts
new file mode 100644
index 00000000000..5f78f42b186
--- /dev/null
+++ b/packages/vuetify/src/composables/theme/colors.ts
@@ -0,0 +1,235 @@
+// Utilities
+import {
+ createRange,
+ darken,
+ getLuma,
+ lighten,
+ mergeDeep,
+ parseColor,
+ RGBtoHex,
+} from '@/util'
+
+// Types
+import type { Color } from '@/util'
+
+type DeepPartial = T extends object ? { [P in keyof T]?: DeepPartial } : T
+
+export interface Colors extends BaseColors, OnColors {
+ [key: string]: Color
+}
+
+interface BaseColors {
+ background: Color
+ surface: Color
+ primary: Color
+ secondary: Color
+ success: Color
+ warning: Color
+ error: Color
+ info: Color
+}
+
+interface OnColors {
+ 'on-background': Color
+ 'on-surface': Color
+ 'on-primary': Color
+ 'on-secondary': Color
+ 'on-success': Color
+ 'on-warning': Color
+ 'on-error': Color
+ 'on-info': Color
+}
+
+export interface VariationsOptions {
+ colors: string[]
+ lighten: number
+ darken: number
+}
+
+export interface InternalThemeDefinition {
+ dark: boolean
+ colors: Colors
+ variables: Record
+}
+
+export type ThemeDefinition = DeepPartial
+
+export interface InternalThemeOptions {
+ cspNonce?: string
+ isDisabled: boolean
+ defaultTheme: 'light' | 'dark' | 'system' | string & {}
+ prefix: string
+ variations: false | VariationsOptions
+ themes: Record
+ stylesheetId: string
+ scope?: string
+ scoped: boolean
+ utilities: boolean
+}
+
+export type ThemeOptions = false | {
+ cspNonce?: string
+ defaultTheme?: 'light' | 'dark' | 'system' | string & {}
+ variations?: false | VariationsOptions
+ themes?: Record
+ stylesheetId?: string
+ scope?: string
+ utilities?: boolean
+}
+
+export function genDefaults () {
+ return {
+ defaultTheme: 'system',
+ prefix: 'v-',
+ variations: { colors: [], lighten: 0, darken: 0 },
+ themes: {
+ light: {
+ dark: false,
+ colors: {
+ background: '#FFFFFF',
+ surface: '#FFFFFF',
+ 'surface-bright': '#FFFFFF',
+ 'surface-light': '#EEEEEE',
+ 'surface-variant': '#424242',
+ 'on-surface-variant': '#EEEEEE',
+ primary: '#1867C0',
+ 'primary-darken-1': '#1F5592',
+ secondary: '#48A9A6',
+ 'secondary-darken-1': '#018786',
+ error: '#B00020',
+ info: '#2196F3',
+ success: '#4CAF50',
+ warning: '#FB8C00',
+ },
+ variables: {
+ 'border-color': '#000000',
+ 'border-opacity': 0.12,
+ 'shadow-color': '#000000',
+ 'high-emphasis-opacity': 0.87,
+ 'medium-emphasis-opacity': 0.60,
+ 'disabled-opacity': 0.38,
+ 'idle-opacity': 0.04,
+ 'hover-opacity': 0.04,
+ 'focus-opacity': 0.12,
+ 'selected-opacity': 0.08,
+ 'activated-opacity': 0.12,
+ 'pressed-opacity': 0.12,
+ 'dragged-opacity': 0.08,
+ 'theme-kbd': '#EEEEEE',
+ 'theme-on-kbd': '#000000',
+ 'theme-code': '#F5F5F5',
+ 'theme-on-code': '#000000',
+ 'theme-on-dark': '#FFF',
+ 'theme-on-light': '#000',
+ 'elevation-overlay-color': 'black',
+ 'elevation-overlay-opacity-step': '2%',
+ },
+ },
+ dark: {
+ dark: true,
+ colors: {
+ background: '#121212',
+ surface: '#212121',
+ 'surface-bright': '#ccbfd6',
+ 'surface-light': '#424242',
+ 'surface-variant': '#c8c8c8',
+ 'on-surface-variant': '#000000',
+ primary: '#2196F3',
+ 'primary-darken-1': '#277CC1',
+ secondary: '#54B6B2',
+ 'secondary-darken-1': '#48A9A6',
+ error: '#CF6679',
+ info: '#2196F3',
+ success: '#4CAF50',
+ warning: '#FB8C00',
+ },
+ variables: {
+ 'border-color': '#FFFFFF',
+ 'border-opacity': 0.12,
+ 'shadow-color': '#000000',
+ 'high-emphasis-opacity': 1,
+ 'medium-emphasis-opacity': 0.70,
+ 'disabled-opacity': 0.50,
+ 'idle-opacity': 0.10,
+ 'hover-opacity': 0.04,
+ 'focus-opacity': 0.12,
+ 'selected-opacity': 0.08,
+ 'activated-opacity': 0.12,
+ 'pressed-opacity': 0.16,
+ 'dragged-opacity': 0.08,
+ 'theme-kbd': '#424242',
+ 'theme-on-kbd': '#FFFFFF',
+ 'theme-code': '#343434',
+ 'theme-on-code': '#CCCCCC',
+ 'theme-on-dark': '#FFF',
+ 'theme-on-light': '#000',
+ 'elevation-overlay-color': 'white',
+ 'elevation-overlay-opacity-step': '2%',
+ },
+ },
+ },
+ stylesheetId: 'vuetify-theme-stylesheet',
+ scoped: false,
+ utilities: true,
+ }
+}
+
+export function parseThemeOptions (options: ThemeOptions = genDefaults()): InternalThemeOptions {
+ const defaults = genDefaults()
+
+ if (!options) return { ...defaults, isDisabled: true } as any
+
+ return mergeDeep(defaults, options) as InternalThemeOptions
+}
+
+export function genCssVariables (theme: InternalThemeDefinition, prefix: string) {
+ const lightOverlay = theme.dark ? 2 : 1
+ const darkOverlay = theme.dark ? 1 : 2
+
+ const variables: string[] = []
+ for (const [key, value] of Object.entries(theme.colors)) {
+ const rgb = parseColor(value)
+ variables.push(`--${prefix}theme-${key}: ${rgb.r},${rgb.g},${rgb.b}` + (rgb.a == null ? '' : `,${rgb.a}`))
+ if (!key.startsWith('on-')) {
+ variables.push(`--${prefix}theme-${key}-overlay-multiplier: ${getLuma(value) > 0.18 ? lightOverlay : darkOverlay}`)
+ }
+ }
+
+ for (const [key, value] of Object.entries(theme.variables)) {
+ const color = typeof value === 'string' && value.startsWith('#') ? parseColor(value) : undefined
+ const rgb = color ? `${color.r}, ${color.g}, ${color.b}` : undefined
+ variables.push(`--${prefix}${key}: ${rgb ?? value}`)
+ }
+
+ return variables
+}
+
+function genVariation (name: string, color: Color, variations: VariationsOptions | false) {
+ const object: Record = {}
+ if (variations) {
+ for (const variation of (['lighten', 'darken'] as const)) {
+ const fn = variation === 'lighten' ? lighten : darken
+ for (const amount of createRange(variations[variation], 1)) {
+ object[`${name}-${variation}-${amount}`] = RGBtoHex(fn(parseColor(color), amount))
+ }
+ }
+ }
+ return object
+}
+
+export function genVariations (colors: InternalThemeDefinition['colors'], variations: VariationsOptions | false) {
+ if (!variations) return {}
+
+ let variationColors = {}
+ for (const name of variations.colors) {
+ const color = colors[name]
+
+ if (!color) continue
+
+ variationColors = {
+ ...variationColors,
+ ...genVariation(name, color, variations),
+ }
+ }
+ return variationColors
+}
diff --git a/packages/vuetify/src/composables/theme/index.ts b/packages/vuetify/src/composables/theme/index.ts
new file mode 100644
index 00000000000..642ccc39ced
--- /dev/null
+++ b/packages/vuetify/src/composables/theme/index.ts
@@ -0,0 +1,235 @@
+// Utilities
+import {
+ createTheme as createV0Theme,
+ usePrefersDark,
+} from '@vuetify/v0'
+import {
+ computed,
+ inject,
+ provide,
+ ref,
+ shallowRef,
+ toRef,
+ watch,
+} from 'vue'
+import { VuetifyThemeAdapter } from './adapter'
+import {
+ genVariations,
+ parseThemeOptions,
+} from './colors'
+import {
+ consoleWarn,
+ getCurrentInstance,
+ mergeDeep,
+ propsFactory,
+} from '@/util'
+
+// Types
+import type { App, DeepReadonly, InjectionKey, Ref } from 'vue'
+import type { Colors, InternalThemeDefinition, ThemeOptions } from './colors'
+
+export type { Colors, ThemeDefinition, ThemeOptions } from './colors'
+
+export interface ThemeInstance {
+ change: (themeName: string) => void
+ cycle: (themeArray?: string[]) => void
+ toggle: (themeArray?: [string, string]) => void
+
+ readonly themes: Ref>
+
+ readonly name: Readonly][>
+ readonly current: DeepReadonly][>
+ readonly computedThemes: DeepReadonly][>>
+ readonly prefix: string
+
+ readonly themeClasses: Readonly][>
+}
+
+export const ThemeSymbol: InjectionKey = Symbol.for('vuetify:theme')
+
+export const makeThemeProps = propsFactory({
+ theme: String,
+}, 'theme')
+
+// Composables
+export function createTheme (options?: ThemeOptions): ThemeInstance & { install: (app: App) => void } {
+ const parsedOptions = parseThemeOptions(options)
+ const themes = ref(parsedOptions.themes)
+
+ // Build processed themes with variations + on-colors for v0 registration
+ function buildV0Themes () {
+ const processed: Record }> = {}
+ for (const [themeName, original] of Object.entries(themes.value)) {
+ const defaultTheme = original.dark || themeName === 'dark'
+ ? themes.value.dark
+ : themes.value.light
+
+ const merged = mergeDeep(defaultTheme, original) as InternalThemeDefinition
+ const colors = {
+ ...merged.colors,
+ ...genVariations(merged.colors, parsedOptions.variations),
+ }
+
+ processed[themeName] = {
+ dark: merged.dark,
+ colors: colors as Record,
+ }
+ }
+ return processed
+ }
+
+ // Resolve default theme — v0 doesn't understand 'system'
+ const isSystemDefault = parsedOptions.defaultTheme === 'system'
+ const { matches: prefersDark } = usePrefersDark()
+ const resolvedDefault = isSystemDefault
+ ? (prefersDark.value ? 'dark' : 'light')
+ : parsedOptions.defaultTheme
+
+ // Create v0 theme instance
+ const v0Theme = createV0Theme({
+ reactive: true,
+ foreground: true,
+ default: resolvedDefault,
+ themes: buildV0Themes(),
+ })
+
+ // Bridge v0's selectedId to Vuetify's name ref
+ const _name = shallowRef(parsedOptions.defaultTheme)
+
+ const name = computed({
+ get () {
+ if (_name.value === 'system') {
+ return prefersDark.value ? 'dark' : 'light'
+ }
+ return String(v0Theme.selectedId.value ?? resolvedDefault)
+ },
+ set (val: string) {
+ _name.value = val
+ if (val !== 'system') {
+ v0Theme.select(val)
+ }
+ },
+ })
+
+ // When system preference changes and we're in system mode, update v0 selection
+ watch(prefersDark, dark => {
+ if (_name.value === 'system') {
+ v0Theme.select(dark ? 'dark' : 'light')
+ }
+ })
+
+ const computedThemes = computed(() => {
+ const acc: Record = {}
+ const v0Colors = v0Theme.colors.value
+
+ for (const [themeName, original] of Object.entries(themes.value)) {
+ const defaultTheme = original.dark || themeName === 'dark'
+ ? themes.value.dark
+ : themes.value.light
+
+ const merged = mergeDeep(defaultTheme, original) as InternalThemeDefinition
+
+ acc[themeName] = {
+ dark: merged.dark,
+ variables: merged.variables,
+ colors: (v0Colors[themeName] ?? merged.colors) as Colors,
+ }
+ }
+ return acc
+ })
+
+ const current = toRef(() => computedThemes.value[name.value])
+
+ const themeClasses = toRef(() => parsedOptions.isDisabled ? undefined : `${parsedOptions.prefix}theme--${name.value}`)
+ const themeNames = toRef(() => Object.keys(computedThemes.value))
+
+ const adapter = new VuetifyThemeAdapter({
+ cspNonce: parsedOptions.cspNonce,
+ scope: parsedOptions.scope,
+ stylesheetId: parsedOptions.stylesheetId,
+ prefix: parsedOptions.prefix,
+ utilities: parsedOptions.utilities,
+ })
+
+ function install (app: App) {
+ if (parsedOptions.isDisabled) return
+
+ adapter.setThemes(computedThemes.value)
+ adapter.setup(app, {
+ colors: v0Theme.colors as any,
+ selectedId: v0Theme.selectedId as any,
+ isDark: v0Theme.isDark as any,
+ })
+
+ watch(computedThemes, val => {
+ adapter.setThemes(val)
+ })
+ }
+
+ function change (themeName: string) {
+ if (themeName !== 'system' && !themeNames.value.includes(themeName)) {
+ consoleWarn(`Theme "${themeName}" not found on the Vuetify theme instance`)
+ return
+ }
+
+ name.value = themeName
+ }
+
+ function cycle (themeArray: string[] = themeNames.value) {
+ const currentIndex = themeArray.indexOf(name.value)
+ const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % themeArray.length
+
+ change(themeArray[nextIndex])
+ }
+
+ function toggle (themeArray: [string, string] = ['light', 'dark']) {
+ cycle(themeArray)
+ }
+
+ return {
+ install,
+ change,
+ cycle,
+ toggle,
+ name,
+ themes,
+ current,
+ computedThemes,
+ prefix: parsedOptions.prefix,
+ themeClasses,
+ }
+}
+
+export function provideTheme (props: { theme?: string }) {
+ getCurrentInstance('provideTheme')
+
+ const theme = inject(ThemeSymbol, null)
+
+ if (!theme) throw new Error('Could not find Vuetify theme injection')
+
+ const name = toRef(() => props.theme ?? theme.name.value)
+ const current = toRef(() => theme.themes.value[name.value])
+
+ const themeClasses = toRef(() => `${theme.prefix}theme--${name.value}`)
+
+ const newTheme: ThemeInstance = {
+ ...theme,
+ name,
+ current,
+ themeClasses,
+ }
+
+ provide(ThemeSymbol, newTheme)
+
+ return newTheme
+}
+
+export function useTheme () {
+ getCurrentInstance('useTheme')
+
+ const theme = inject(ThemeSymbol, null)
+
+ if (!theme) throw new Error('Could not find Vuetify theme injection')
+
+ return theme
+}
]