Skip to content

Commit ad035c3

Browse files
authored
feat: add ColorScheme API for light/dark theme support (#22718)
Adds a ColorScheme API for controlling light and dark theme variants in Vaadin applications. Includes an `@ColorScheme` annotation for initial configuration and `Page.setColorScheme()` for runtime switching. Uses the HTML theme attribute approach for CSS compatibility. Initial Configuration - `@ColorScheme` annotation for AppShell to set the initial color scheme - Applied as inline style on <html> element during bootstrap Runtime Control - Page.setColorScheme(ColorScheme.Value) / Page.getColorScheme() for dynamic switching - ColorScheme.Value enum: LIGHT, DARK, LIGHT_DARK, DARK_LIGHT, NORMAL - Server-side state synchronized via ExtendedClientDetails Implementation Details - Sets theme attribute on <html> element (e.g., <html theme="dark">) - Clears inline style.colorScheme to allow theme CSS to set the property - Enables CSS selectors: html[theme~="dark"] { color-scheme: dark; } - Client sends color scheme to server via v-cs parameter in Flow.ts - Automatic string-to-enum conversion via ColorScheme.Value.fromString() Backward Compatibility - Deprecated `@Theme(variant)` attribute in favor of `@ColorScheme` - Existing variant usage continues to work Fixes #15354
1 parent 299e040 commit ad035c3

File tree

17 files changed

+846
-13
lines changed

17 files changed

+846
-13
lines changed

flow-client/src/main/frontend/Flow.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,20 @@ export class Flow {
539539
params['v-np'] = ($wnd as any).navigator.platform;
540540
}
541541

542+
/* Color scheme from CSS color-scheme property */
543+
const colorScheme = getComputedStyle(document.documentElement).colorScheme.trim();
544+
// "normal" is the default value and means no color scheme is set
545+
params['v-cs'] = colorScheme && colorScheme !== 'normal' ? colorScheme : '';
546+
/* Theme name - detect which theme is in use */
547+
const computedStyle = getComputedStyle(document.documentElement);
548+
let themeName = '';
549+
if (computedStyle.getPropertyValue('--vaadin-lumo-theme').trim()) {
550+
themeName = 'lumo';
551+
} else if (computedStyle.getPropertyValue('--vaadin-aura-theme').trim()) {
552+
themeName = 'aura';
553+
}
554+
params['v-tn'] = themeName;
555+
542556
/* Stringify each value (they are parsed on the server side) */
543557
const stringParams: Record<string, string> = {};
544558
Object.keys(params).forEach((key) => {

flow-server/src/main/java/com/vaadin/flow/component/internal/UIInternals.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1362,7 +1362,7 @@ public ExtendedClientDetails getExtendedClientDetails() {
13621362
// Create placeholder with default values
13631363
extendedClientDetails = new ExtendedClientDetails(ui, null, null,
13641364
null, null, null, null, null, null, null, null, null, null,
1365-
null, null, null, null);
1365+
null, null, null, null, null, null);
13661366
}
13671367
return extendedClientDetails;
13681368
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/*
2+
* Copyright 2000-2025 Vaadin Ltd.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
* use this file except in compliance with the License. You may obtain a copy of
6+
* the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations under
14+
* the License.
15+
*/
16+
package com.vaadin.flow.component.page;
17+
18+
import java.lang.annotation.Documented;
19+
import java.lang.annotation.ElementType;
20+
import java.lang.annotation.Inherited;
21+
import java.lang.annotation.Retention;
22+
import java.lang.annotation.RetentionPolicy;
23+
import java.lang.annotation.Target;
24+
25+
/**
26+
* Defines the color scheme for the application using the CSS color-scheme
27+
* property.
28+
* <p>
29+
* This annotation should be placed on a class that implements
30+
* {@link com.vaadin.flow.component.page.AppShellConfigurator} to set the
31+
* initial color scheme for the entire application.
32+
* <p>
33+
* Example usage:
34+
*
35+
* <pre>
36+
* &#64;ColorScheme(ColorScheme.Value.DARK)
37+
* public class AppShell implements AppShellConfigurator {
38+
* }
39+
* </pre>
40+
* <p>
41+
* The color scheme can also be changed programmatically at runtime using
42+
* {@link Page#setColorScheme(ColorScheme.Value)}.
43+
*
44+
* @see Page#setColorScheme(ColorScheme.Value)
45+
* @see Page#getColorScheme()
46+
*/
47+
@Retention(RetentionPolicy.RUNTIME)
48+
@Target(ElementType.TYPE)
49+
@Inherited
50+
@Documented
51+
public @interface ColorScheme {
52+
53+
/**
54+
* The initial color scheme for the application.
55+
*
56+
* @return the color scheme value
57+
*/
58+
Value value() default Value.NORMAL;
59+
60+
/**
61+
* Enumeration of supported color scheme values.
62+
* <p>
63+
* These values correspond to the CSS color-scheme property values and
64+
* control how the browser renders UI elements and how the application
65+
* responds to system color scheme preferences.
66+
*/
67+
enum Value {
68+
/**
69+
* Light color scheme only. The application will use a light theme
70+
* regardless of system preferences.
71+
*/
72+
LIGHT("light"),
73+
74+
/**
75+
* Dark color scheme only. The application will use a dark theme
76+
* regardless of system preferences.
77+
*/
78+
DARK("dark"),
79+
80+
/**
81+
* Supports both light and dark color schemes, with a preference for
82+
* light. The application can adapt to system preferences but defaults
83+
* to light mode.
84+
*/
85+
LIGHT_DARK("light dark"),
86+
87+
/**
88+
* Supports both light and dark color schemes, with a preference for
89+
* dark. The application can adapt to system preferences but defaults to
90+
* dark mode.
91+
*/
92+
DARK_LIGHT("dark light"),
93+
94+
/**
95+
* Normal/default color scheme. Indicates that no specific color scheme
96+
* preference is set via this API. The actual color scheme used will
97+
* depend on other factors such as the browser's default behavior,
98+
* system preferences, or other meta tags like
99+
* {@code <meta name="color-scheme" content="dark">}.
100+
*/
101+
NORMAL("normal");
102+
103+
private final String value;
104+
105+
Value(String value) {
106+
this.value = value;
107+
}
108+
109+
/**
110+
* Gets the CSS color-scheme property value.
111+
*
112+
* @return the CSS value string
113+
*/
114+
public String getValue() {
115+
return value;
116+
}
117+
118+
/**
119+
* Gets the theme attribute value.
120+
* <p>
121+
* For multi-value color schemes (e.g., "light dark"), this returns the
122+
* value with spaces replaced by hyphens (e.g., "light-dark") for use in
123+
* the theme attribute.
124+
*
125+
* @return the theme attribute value
126+
*/
127+
public String getThemeValue() {
128+
return value.replace(' ', '-');
129+
}
130+
131+
/**
132+
* Converts a string to a ColorScheme.Value enum.
133+
*
134+
* @param value
135+
* the CSS color-scheme value string
136+
* @return the corresponding enum value, or NORMAL if not recognized
137+
*/
138+
public static Value fromString(String value) {
139+
if (value == null || value.isEmpty()) {
140+
return NORMAL;
141+
}
142+
for (Value v : values()) {
143+
if (v.value.equals(value)) {
144+
return v;
145+
}
146+
}
147+
return NORMAL;
148+
}
149+
}
150+
}

flow-server/src/main/java/com/vaadin/flow/component/page/ExtendedClientDetails.java

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ public class ExtendedClientDetails implements Serializable {
6060
private double devicePixelRatio = -1.0D;
6161
private String windowName;
6262
private String navigatorPlatform;
63+
private ColorScheme.Value colorScheme = ColorScheme.Value.NORMAL;
64+
private String themeName;
6365

6466
/**
6567
* For internal use only. Updates all properties in the class according to
@@ -100,14 +102,18 @@ public class ExtendedClientDetails implements Serializable {
100102
* a unique browser window name which persists on reload
101103
* @param navigatorPlatform
102104
* navigation platform received from the browser
105+
* @param colorScheme
106+
* the current color scheme
107+
* @param themeName
108+
* the theme name (e.g., "lumo", "aura")
103109
*/
104110
public ExtendedClientDetails(UI ui, String screenWidth, String screenHeight,
105111
String windowInnerWidth, String windowInnerHeight,
106112
String bodyClientWidth, String bodyClientHeight, String tzOffset,
107113
String rawTzOffset, String dstShift, String dstInEffect,
108114
String tzId, String curDate, String touchDevice,
109115
String devicePixelRatio, String windowName,
110-
String navigatorPlatform) {
116+
String navigatorPlatform, String colorScheme, String themeName) {
111117
this.ui = ui;
112118
if (screenWidth != null) {
113119
try {
@@ -184,6 +190,8 @@ public ExtendedClientDetails(UI ui, String screenWidth, String screenHeight,
184190

185191
this.windowName = windowName;
186192
this.navigatorPlatform = navigatorPlatform;
193+
setColorScheme(ColorScheme.Value.fromString(colorScheme));
194+
this.themeName = themeName;
187195
}
188196

189197
/**
@@ -397,6 +405,36 @@ public boolean isIOS() {
397405
&& navigatorPlatform.startsWith("iPod"));
398406
}
399407

408+
/**
409+
* Gets the color scheme.
410+
*
411+
* @return the color scheme, never {@code null}
412+
*/
413+
public ColorScheme.Value getColorScheme() {
414+
return colorScheme;
415+
}
416+
417+
/**
418+
* Gets the theme name.
419+
*
420+
* @return the theme name (e.g., "lumo", "aura"), or empty string if not
421+
* detected
422+
*/
423+
public String getThemeName() {
424+
return themeName;
425+
}
426+
427+
/**
428+
* Updates the color scheme. For internal use only.
429+
*
430+
* @param colorScheme
431+
* the new color scheme
432+
*/
433+
void setColorScheme(ColorScheme.Value colorScheme) {
434+
this.colorScheme = colorScheme == null ? ColorScheme.Value.NORMAL
435+
: colorScheme;
436+
}
437+
400438
/**
401439
* Creates an ExtendedClientDetails instance from browser details JSON
402440
* object. This is intended for internal use when browser details are
@@ -446,7 +484,9 @@ public static ExtendedClientDetails fromJson(UI ui, JsonNode json) {
446484
getStringElseNull.apply("v-td"),
447485
getStringElseNull.apply("v-pr"),
448486
getStringElseNull.apply("v-wn"),
449-
getStringElseNull.apply("v-np"));
487+
getStringElseNull.apply("v-np"),
488+
getStringElseNull.apply("v-cs"),
489+
getStringElseNull.apply("v-tn"));
450490
}
451491

452492
/**

flow-server/src/main/java/com/vaadin/flow/component/page/Page.java

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,49 @@ public void setTitle(String title) {
8383
ui.getInternals().setTitle(title);
8484
}
8585

86+
/**
87+
* Sets the color scheme for the page.
88+
* <p>
89+
* The color scheme is applied via both a theme attribute and the
90+
* color-scheme CSS property on the html element. The theme attribute allows
91+
* CSS to target different color schemes (e.g.,
92+
* {@code html[theme~="dark"]}), while the color-scheme property ensures
93+
* browser UI adaptation works even for custom themes that don't define
94+
* their own color-scheme CSS rules.
95+
*
96+
* @param colorScheme
97+
* the color scheme to set (e.g., ColorScheme.Value.DARK,
98+
* ColorScheme.Value.LIGHT), or {@code null} to reset to NORMAL
99+
*/
100+
public void setColorScheme(ColorScheme.Value colorScheme) {
101+
if (colorScheme == null || colorScheme == ColorScheme.Value.NORMAL) {
102+
executeJs("""
103+
document.documentElement.removeAttribute('theme');
104+
document.documentElement.style.colorScheme = '';
105+
""");
106+
getExtendedClientDetails().setColorScheme(ColorScheme.Value.NORMAL);
107+
} else {
108+
executeJs("""
109+
document.documentElement.setAttribute('theme', $0);
110+
document.documentElement.style.colorScheme = $1;
111+
""", colorScheme.getThemeValue(), colorScheme.getValue());
112+
getExtendedClientDetails().setColorScheme(colorScheme);
113+
}
114+
}
115+
116+
/**
117+
* Gets the color scheme for the page.
118+
* <p>
119+
* Note that this method returns the server-side cached value and will not
120+
* detect color scheme changes made directly via JavaScript or browser
121+
* developer tools.
122+
*
123+
* @return the color scheme value, never {@code null}
124+
*/
125+
public ColorScheme.Value getColorScheme() {
126+
return getExtendedClientDetails().getColorScheme();
127+
}
128+
86129
/**
87130
* Adds the given style sheet to the page and ensures that it is loaded
88131
* successfully.

flow-server/src/main/java/com/vaadin/flow/server/communication/IndexHtmlRequestHandler.java

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ public boolean synchronizedHandleRequest(VaadinSession session,
177177
}
178178

179179
addDevBundleTheme(indexDocument, context);
180-
applyThemeVariant(indexDocument, context);
180+
applyColorScheme(indexDocument, context);
181181

182182
if (config.isDevToolsEnabled()) {
183183
addDevTools(indexDocument, config, session, request);
@@ -253,8 +253,26 @@ private static void addDevBundleTheme(Document document,
253253
}
254254
}
255255

256-
private void applyThemeVariant(Document indexDocument,
256+
private void applyColorScheme(Document indexDocument,
257257
VaadinContext context) {
258+
// Check for @ColorScheme annotation first
259+
AppShellRegistry registry = AppShellRegistry.getInstance(context);
260+
Class<?> shell = registry.getShell();
261+
if (shell != null) {
262+
com.vaadin.flow.component.page.ColorScheme colorSchemeAnnotation = shell
263+
.getAnnotation(
264+
com.vaadin.flow.component.page.ColorScheme.class);
265+
if (colorSchemeAnnotation != null) {
266+
String colorScheme = colorSchemeAnnotation.value()
267+
.getThemeValue();
268+
if (!colorScheme.isEmpty() && !colorScheme.equals("normal")) {
269+
indexDocument.head().parent().attr("theme", colorScheme);
270+
}
271+
}
272+
}
273+
274+
// Also apply from deprecated @Theme variant attribute for backwards
275+
// compatibility
258276
ThemeUtils.getThemeAnnotation(context).ifPresent(theme -> {
259277
String variant = theme.variant();
260278
if (!variant.isEmpty()) {

flow-server/src/main/java/com/vaadin/flow/server/startup/VaadinAppShellInitializer.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import com.vaadin.flow.component.dependency.StyleSheet;
3636
import com.vaadin.flow.component.page.AppShellConfigurator;
3737
import com.vaadin.flow.component.page.BodySize;
38+
import com.vaadin.flow.component.page.ColorScheme;
3839
import com.vaadin.flow.component.page.Inline;
3940
import com.vaadin.flow.component.page.Meta;
4041
import com.vaadin.flow.component.page.Push;
@@ -61,8 +62,9 @@
6162
*/
6263
@HandlesTypes({ AppShellConfigurator.class, Meta.class, Meta.Container.class,
6364
PWA.class, Inline.class, Inline.Container.class, Viewport.class,
64-
BodySize.class, PageTitle.class, Push.class, Theme.class, NoTheme.class,
65-
StyleSheet.class, StyleSheet.Container.class })
65+
BodySize.class, PageTitle.class, Push.class, ColorScheme.class,
66+
Theme.class, NoTheme.class, StyleSheet.class,
67+
StyleSheet.Container.class })
6668
// @WebListener is needed so that servlet containers know that they have to run
6769
// it
6870
@WebListener

flow-server/src/main/java/com/vaadin/flow/theme/Theme.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,15 @@
100100

101101
/**
102102
* The theme variant, if any.
103+
* <p>
104+
* <b>Deprecated:</b> Use {@link com.vaadin.flow.component.page.ColorScheme}
105+
* annotation instead to set the color scheme for the application.
103106
*
104107
* @return the theme variant
108+
* @deprecated Use {@link com.vaadin.flow.component.page.ColorScheme}
109+
* annotation instead
105110
*/
111+
@Deprecated(since = "25.0", forRemoval = true)
106112
String variant() default "";
107113

108114
/**

0 commit comments

Comments
 (0)