Skip to content

Commit d72ace6

Browse files
committed
POC: view transitions
1 parent ad94d98 commit d72ace6

File tree

5 files changed

+167
-67
lines changed

5 files changed

+167
-67
lines changed

src/common/dom/view_transition.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/**
2+
* Trigger a view transition if supported by the browser
3+
* @param updateCallback - Callback function that updates the DOM
4+
* @returns Promise that resolves when the transition is complete
5+
*/
6+
export const startViewTransition = async (
7+
updateCallback: () => void | Promise<void>
8+
): Promise<void> => {
9+
// Check if View Transitions API is supported
10+
if (
11+
!document.startViewTransition ||
12+
window.matchMedia("(prefers-reduced-motion: reduce)").matches
13+
) {
14+
// Fallback: just run the update without transition
15+
await updateCallback();
16+
return;
17+
}
18+
19+
// Start the view transition
20+
const transition = document.startViewTransition(async () => {
21+
await updateCallback();
22+
});
23+
24+
try {
25+
await transition.finished;
26+
} catch (error) {
27+
// Transitions can be skipped, which is fine
28+
// eslint-disable-next-line no-console
29+
console.debug("View transition skipped or failed:", error);
30+
}
31+
};
32+
33+
/**
34+
* Helper to apply view transition on first render
35+
* @param _element - The element to observe (unused, kept for API consistency)
36+
* @param callback - Callback when element is first rendered
37+
*/
38+
export const applyViewTransitionOnLoad = (
39+
_element: HTMLElement,
40+
callback?: () => void
41+
): void => {
42+
if (!document.startViewTransition) {
43+
callback?.();
44+
return;
45+
}
46+
47+
// Use requestAnimationFrame to ensure DOM is ready
48+
requestAnimationFrame(() => {
49+
startViewTransition(() => {
50+
callback?.();
51+
});
52+
});
53+
};

src/components/ha-sidebar.ts

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
import { classMap } from "lit/directives/class-map";
2929
import memoizeOne from "memoize-one";
3030
import { fireEvent } from "../common/dom/fire_event";
31+
import { applyViewTransitionOnLoad } from "../common/dom/view_transition";
3132
import { toggleAttribute } from "../common/dom/toggle_attribute";
3233
import { stringCompare } from "../common/string/compare";
3334
import { throttle } from "../common/util/throttle";
@@ -41,7 +42,7 @@ import { updateCanInstall } from "../data/update";
4142
import { showEditSidebarDialog } from "../dialogs/sidebar/show-dialog-edit-sidebar";
4243
import { SubscribeMixin } from "../mixins/subscribe-mixin";
4344
import { actionHandler } from "../panels/lovelace/common/directives/action-handler-directive";
44-
import { haStyleAnimations, haStyleScrollbar } from "../resources/styles";
45+
import { haStyleScrollbar, haStyleViewTransitions } from "../resources/styles";
4546
import type { HomeAssistant, PanelInfo, Route } from "../types";
4647
import "./ha-fade-in";
4748
import "./ha-icon";
@@ -306,6 +307,9 @@ class HaSidebar extends SubscribeMixin(LitElement) {
306307
protected firstUpdated(changedProps: PropertyValues) {
307308
super.firstUpdated(changedProps);
308309
this._subscribePersistentNotifications();
310+
311+
// Trigger view transition on initial load
312+
applyViewTransitionOnLoad(this);
309313
}
310314

311315
private _subscribePersistentNotifications(): void {
@@ -326,12 +330,9 @@ class HaSidebar extends SubscribeMixin(LitElement) {
326330
toggleAttribute(this, "expanded", this.alwaysExpand);
327331
}
328332

329-
// Staggered animation for list items based on index
333+
// Set up view transition names for staggered animations
330334
this._listItems.forEach((item, index) => {
331-
(item as HTMLElement).style.setProperty(
332-
"--animation-index",
333-
String(index + 1)
334-
);
335+
(item as HTMLElement).style.viewTransitionName = `sidebar-item-${index}`;
335336
});
336337

337338
if (!changedProps.has("hass")) {
@@ -705,7 +706,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
705706
static get styles(): CSSResultGroup {
706707
return [
707708
haStyleScrollbar,
708-
haStyleAnimations,
709+
haStyleViewTransitions,
709710
css`
710711
:host {
711712
overflow: visible;
@@ -752,14 +753,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
752753
}
753754
.menu ha-icon-button {
754755
color: var(--sidebar-icon-color);
755-
animation: fadeInSlideDown var(--ha-animation-duration) ease-out both;
756-
animation-delay: var(--ha-animation-delay-base) / 2;
757-
}
758-
ha-md-list-item {
759-
animation: fadeInSlideDown var(--ha-animation-duration) ease-out both;
760-
animation-delay: calc(
761-
var(--ha-animation-delay-base) * var(--animation-index, 1) / 2
762-
);
756+
view-transition-name: sidebar-menu-button;
763757
}
764758
.title {
765759
margin-left: 3px;

src/panels/lovelace/hui-root.ts

Lines changed: 44 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ import { ifDefined } from "lit/directives/if-defined";
2525
import memoizeOne from "memoize-one";
2626
import { isComponentLoaded } from "../../common/config/is_component_loaded";
2727
import { fireEvent } from "../../common/dom/fire_event";
28+
import {
29+
applyViewTransitionOnLoad,
30+
startViewTransition,
31+
} from "../../common/dom/view_transition";
2832
import { shouldHandleRequestSelectedEvent } from "../../common/mwc/handle-request-selected-event";
2933
import { goBack, navigate } from "../../common/navigate";
3034
import type { LocalizeKeys } from "../../common/translations/localize";
@@ -72,7 +76,7 @@ import {
7276
} from "../../dialogs/quick-bar/show-dialog-quick-bar";
7377
import { showShortcutsDialog } from "../../dialogs/shortcuts/show-shortcuts-dialog";
7478
import { showVoiceCommandDialog } from "../../dialogs/voice-command-dialog/show-ha-voice-command-dialog";
75-
import { haStyle, haStyleAnimations } from "../../resources/styles";
79+
import { haStyle, haStyleViewTransitions } from "../../resources/styles";
7680
import type { HomeAssistant, PanelInfo } from "../../types";
7781
import { documentationUrl } from "../../util/documentation-url";
7882
import { showToast } from "../../util/toast";
@@ -623,6 +627,9 @@ class HUIRoot extends LitElement {
623627
window.addEventListener("scroll", this._handleWindowScroll, {
624628
passive: true,
625629
});
630+
631+
// Trigger view transition on initial load
632+
applyViewTransitionOnLoad(this);
626633
}
627634

628635
public connectedCallback(): void {
@@ -1162,43 +1169,45 @@ class HUIRoot extends LitElement {
11621169
// Recreate a new element to clear the applied themes.
11631170
const root = this._viewRoot;
11641171

1165-
if (root.lastChild) {
1166-
root.removeChild(root.lastChild);
1167-
}
1172+
startViewTransition(() => {
1173+
if (root.lastChild) {
1174+
root.removeChild(root.lastChild);
1175+
}
11681176

1169-
if (viewIndex === "hass-unused-entities") {
1170-
const unusedEntities = document.createElement("hui-unused-entities");
1171-
// Wait for promise to resolve so that the element has been upgraded.
1172-
import("./editor/unused-entities/hui-unused-entities").then(() => {
1173-
unusedEntities.hass = this.hass!;
1174-
unusedEntities.lovelace = this.lovelace!;
1175-
unusedEntities.narrow = this.narrow;
1176-
});
1177-
root.appendChild(unusedEntities);
1178-
return;
1179-
}
1177+
if (viewIndex === "hass-unused-entities") {
1178+
const unusedEntities = document.createElement("hui-unused-entities");
1179+
// Wait for promise to resolve so that the element has been upgraded.
1180+
import("./editor/unused-entities/hui-unused-entities").then(() => {
1181+
unusedEntities.hass = this.hass!;
1182+
unusedEntities.lovelace = this.lovelace!;
1183+
unusedEntities.narrow = this.narrow;
1184+
});
1185+
root.appendChild(unusedEntities);
1186+
return;
1187+
}
11801188

1181-
let view;
1182-
const viewConfig = this.config.views[viewIndex];
1189+
let view;
1190+
const viewConfig = this.config.views[viewIndex];
11831191

1184-
if (!viewConfig) {
1185-
this.lovelace!.setEditMode(true);
1186-
return;
1187-
}
1192+
if (!viewConfig) {
1193+
this.lovelace!.setEditMode(true);
1194+
return;
1195+
}
11881196

1189-
if (!force && this._viewCache![viewIndex]) {
1190-
view = this._viewCache![viewIndex];
1191-
} else {
1192-
view = document.createElement("hui-view");
1193-
view.index = viewIndex;
1194-
this._viewCache![viewIndex] = view;
1195-
}
1197+
if (!force && this._viewCache![viewIndex]) {
1198+
view = this._viewCache![viewIndex];
1199+
} else {
1200+
view = document.createElement("hui-view");
1201+
view.index = viewIndex;
1202+
this._viewCache![viewIndex] = view;
1203+
}
11961204

1197-
view.lovelace = this.lovelace;
1198-
view.hass = this.hass;
1199-
view.narrow = this.narrow;
1205+
view.lovelace = this.lovelace;
1206+
view.hass = this.hass;
1207+
view.narrow = this.narrow;
12001208

1201-
root.appendChild(view);
1209+
root.appendChild(view);
1210+
});
12021211
}
12031212

12041213
private _openShortcutDialog(ev: Event) {
@@ -1209,7 +1218,7 @@ class HUIRoot extends LitElement {
12091218
static get styles(): CSSResultGroup {
12101219
return [
12111220
haStyle,
1212-
haStyleAnimations,
1221+
haStyleViewTransitions,
12131222
css`
12141223
:host {
12151224
-ms-user-select: none;
@@ -1264,8 +1273,7 @@ class HUIRoot extends LitElement {
12641273
padding: 0px 12px;
12651274
font-weight: var(--ha-font-weight-normal);
12661275
box-sizing: border-box;
1267-
animation: fadeIn var(--ha-animation-duration) ease-out both;
1268-
animation-delay: var(--ha-animation-delay-base);
1276+
view-transition-name: lovelace-toolbar;
12691277
}
12701278
.narrow .toolbar {
12711279
padding: 0 4px;
@@ -1414,8 +1422,7 @@ class HUIRoot extends LitElement {
14141422
hui-view-container > * {
14151423
flex: 1 1 100%;
14161424
max-width: 100%;
1417-
animation: fadeInSlideDown var(--ha-animation-duration) ease-out both;
1418-
animation-delay: var(--ha-animation-delay-base);
1425+
view-transition-name: lovelace-view;
14191426
}
14201427
/**
14211428
* In edit mode we have the tab bar on a new line *

src/resources/styles.ts

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -200,33 +200,72 @@ export const baseEntrypointStyles = css`
200200
}
201201
`;
202202

203-
export const haStyleAnimations = css`
204-
@keyframes fadeIn {
205-
0% {
203+
export const haStyleViewTransitions = css`
204+
/* View Transition animations for sidebar and lovelace */
205+
@media (prefers-reduced-motion: no-preference) {
206+
/* Toolbar fade in */
207+
::view-transition-group(lovelace-toolbar) {
208+
animation-duration: var(--ha-animation-duration);
209+
animation-timing-function: ease-out;
210+
}
211+
::view-transition-new(lovelace-toolbar) {
212+
animation: fade-in var(--ha-animation-duration) ease-out;
213+
animation-delay: var(--ha-animation-delay-base);
214+
}
215+
216+
/* View slide down */
217+
::view-transition-group(lovelace-view) {
218+
animation-duration: var(--ha-animation-duration);
219+
animation-timing-function: ease-out;
220+
}
221+
::view-transition-new(lovelace-view) {
222+
animation: fade-in-slide-down var(--ha-animation-duration) ease-out;
223+
animation-delay: var(--ha-animation-delay-base);
224+
}
225+
226+
/* Sidebar menu button */
227+
::view-transition-group(sidebar-menu-button) {
228+
animation-duration: var(--ha-animation-duration);
229+
animation-timing-function: ease-out;
230+
}
231+
::view-transition-new(sidebar-menu-button) {
232+
animation: fade-in-slide-down var(--ha-animation-duration) ease-out;
233+
animation-delay: calc(var(--ha-animation-delay-base) / 2);
234+
}
235+
236+
/* Sidebar items with staggered animation */
237+
::view-transition-group(sidebar-item-*) {
238+
animation-duration: var(--ha-animation-duration);
239+
animation-timing-function: ease-out;
240+
}
241+
}
242+
243+
@keyframes fade-in {
244+
from {
206245
opacity: 0;
207246
}
208-
100% {
247+
to {
209248
opacity: 1;
210249
}
211250
}
212251
213-
@keyframes fadeInSlideUp {
214-
0% {
252+
@keyframes fade-in-slide-up {
253+
from {
215254
opacity: 0;
216255
transform: translateY(20px);
217256
}
218-
100% {
257+
to {
219258
opacity: 1;
220259
transform: translateY(0);
221260
}
222261
}
223262
224-
@keyframes fadeInSlideDown {
225-
0% {
263+
@keyframes fade-in-slide-down {
264+
from {
226265
opacity: 0;
227266
transform: translateY(-20px);
228267
}
229-
100% {
268+
to {
230269
opacity: 1;
231270
transform: translateY(0);
232271
}

src/resources/theme/core.globals.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,19 @@ export const coreStyles = css`
4646
/* Animation timing */
4747
--ha-animation-duration: 350ms;
4848
--ha-animation-delay-base: 50ms;
49+
}
50+
51+
@media (prefers-reduced-motion: reduce) {
52+
html {
53+
--ha-animation-duration: 150ms;
54+
--ha-animation-delay-base: 20ms;
55+
}
56+
}
4957
50-
@media (prefers-reduced-motion: reduce) {
51-
html {
52-
--ha-animation-duration: 150ms;
53-
--ha-animation-delay-base: 20ms;
54-
}
58+
/* Enable View Transitions API for supported browsers */
59+
@supports (view-transition-name: none) {
60+
:root {
61+
view-transition-name: root;
5562
}
5663
}
5764
`;

0 commit comments

Comments
 (0)