From 53748efbadbd78e5cbe2db9c0757b3844fa5fe01 Mon Sep 17 00:00:00 2001
From: Kalle <38327916+Sendouc@users.noreply.github.com>
Date: Sat, 19 Sep 2026 12:25:29 +0300
Subject: [PATCH] Match page banner preload and animate
---
.../match-page/MatchBanner.module.css | 76 +++++++++++++++++
app/components/match-page/MatchBanner.tsx | 84 ++++++++++++++-----
.../components/SendouQMatchBanner.tsx | 7 ++
.../components/TournamentMatchBanner.tsx | 11 +++
app/styles/utils.css | 6 ++
...026-09-19-match-stage-banner-transition.md | 5 ++
6 files changed, 170 insertions(+), 19 deletions(-)
create mode 100644 changelog/2026-09-19-match-stage-banner-transition.md
diff --git a/app/components/match-page/MatchBanner.module.css b/app/components/match-page/MatchBanner.module.css
index acdc8a1bb..294042722 100644
--- a/app/components/match-page/MatchBanner.module.css
+++ b/app/components/match-page/MatchBanner.module.css
@@ -38,6 +38,82 @@
justify-self: flex-end;
}
+/* only the new image scales (from 1.05): a snapshot smaller than the frame shows its edge */
+::view-transition-group(:global(.stage-banner-swap)) {
+ animation-duration: 0s;
+}
+
+::view-transition-image-pair(:global(.stage-banner-swap)) {
+ clip-path: inset(0 round var(--radius-box));
+}
+
+::view-transition-old(:global(.stage-banner-swap)) {
+ mix-blend-mode: plus-lighter;
+ animation: fade-out 400ms ease both;
+}
+
+::view-transition-new(:global(.stage-banner-swap)) {
+ mix-blend-mode: plus-lighter;
+ animation: settle-in 400ms ease both;
+}
+
+/* map name and pick info: old fades out first, new rises in once the image has settled */
+::view-transition-group(:global(.stage-banner-text)) {
+ animation: none;
+}
+
+/* snapshots are stretched to the group's (new) width by default */
+::view-transition-old(:global(.stage-banner-text)),
+::view-transition-new(:global(.stage-banner-text)) {
+ inline-size: auto;
+ block-size: auto;
+}
+
+/* right-aligned, so a width change must keep the end edge in place */
+::view-transition-old(match-stage-banner-info),
+::view-transition-new(match-stage-banner-info) {
+ inset-inline-start: auto;
+ inset-inline-end: 0;
+}
+
+::view-transition-old(:global(.stage-banner-text)) {
+ animation: fade-out 150ms ease both;
+}
+
+::view-transition-new(:global(.stage-banner-text)) {
+ animation: rise-in 250ms cubic-bezier(0.22, 1, 0.36, 1) 400ms both;
+}
+
+/* unchanged parts; the old snapshot is hidden as stacking it would brighten the 70% opacity labels */
+::view-transition-group(:global(.stage-banner-static)),
+::view-transition-new(:global(.stage-banner-static)) {
+ animation: none;
+}
+
+::view-transition-old(:global(.stage-banner-static)) {
+ display: none;
+}
+
+@keyframes fade-out {
+ to {
+ opacity: 0;
+ }
+}
+
+@keyframes settle-in {
+ from {
+ opacity: 0;
+ transform: scale(1.05);
+ }
+}
+
+@keyframes rise-in {
+ from {
+ opacity: 0;
+ transform: translateY(6px);
+ }
+}
+
.map {
grid-area: map;
display: flex;
diff --git a/app/components/match-page/MatchBanner.tsx b/app/components/match-page/MatchBanner.tsx
index dc0de1895..5570789b8 100644
--- a/app/components/match-page/MatchBanner.tsx
+++ b/app/components/match-page/MatchBanner.tsx
@@ -1,5 +1,7 @@
import clsx from "clsx";
import { Check, X } from "lucide-react";
+import { ViewTransition } from "react";
+import { preload } from "react-dom";
import { useTranslation } from "react-i18next";
import { Avatar } from "~/components/Avatar";
import {
@@ -47,32 +49,76 @@ export function MatchBanner({
const { t } = useTranslation(["game-misc"]);
return (
-
-
- {t(`game-misc:MODE_SHORT_${mode}`)} {t(`game-misc:STAGE_${stageId}`)}
-
-
{children}
+
+
+
+ {t(`game-misc:MODE_SHORT_${mode}`)}{" "}
+ {t(`game-misc:STAGE_${stageId}`)}
+
+
+
+ {children}
+
- {joinPool ? (
-
- ) : null}
- {screenLegal !== undefined ? (
-
- ) : null}
-
+ {joinPool ? (
+
+
+
+ ) : null}
+ {screenLegal !== undefined ? (
+
+
+
+ ) : null}
+
+
);
}
+/** Warms the cache with the remaining maps' banners so the transition to the next one has its image. */
+export function preloadStageBanners(stageIds: StageId[]) {
+ for (const stageId of new Set(stageIds)) {
+ preload(stageBannerImageUrl(stageId), {
+ as: "image",
+ fetchPriority: "low",
+ });
+ }
+}
+
export function MultiMatchBanner({ stageIds }: { stageIds: StageId[] }) {
return (
diff --git a/app/features/sendouq-match/components/SendouQMatchBanner.tsx b/app/features/sendouq-match/components/SendouQMatchBanner.tsx
index 6958c4b33..06c5b5831 100644
--- a/app/features/sendouq-match/components/SendouQMatchBanner.tsx
+++ b/app/features/sendouq-match/components/SendouQMatchBanner.tsx
@@ -9,6 +9,7 @@ import {
MatchBannerContainer,
MatchBannerInfoBadge,
MultiMatchBanner,
+ preloadStageBanners,
} from "~/components/match-page/MatchBanner";
import { MatchBannerBottomRow } from "~/components/match-page/MatchBannerBottomRow";
import { MatchBannerStartedAt } from "~/components/match-page/MatchBannerStartedAt";
@@ -80,6 +81,12 @@ export function SendouQMatchBanner({ data }: { data: SendouQMatchLoaderData }) {
const currentMap = data.match.currentMap;
invariant(currentMap);
+ preloadStageBanners(
+ data.match.mapList
+ .filter((map) => map.winnerGroupId === null)
+ .map((map) => map.stageId),
+ );
+
const isParticipant = Boolean(
SendouQMatch.resolveGroupMemberOf({
groupAlpha: data.match.groupAlpha,
diff --git a/app/features/tournament-match/components/TournamentMatchBanner.tsx b/app/features/tournament-match/components/TournamentMatchBanner.tsx
index 323735516..db2d8ad76 100644
--- a/app/features/tournament-match/components/TournamentMatchBanner.tsx
+++ b/app/features/tournament-match/components/TournamentMatchBanner.tsx
@@ -17,6 +17,7 @@ import {
MatchBannerContainer,
MatchBannerInfoBadge,
MultiMatchBanner,
+ preloadStageBanners,
} from "~/components/match-page/MatchBanner";
import { MatchBannerBottomRow } from "~/components/match-page/MatchBannerBottomRow";
import { MatchBannerStartedAt } from "~/components/match-page/MatchBannerStartedAt";
@@ -54,8 +55,18 @@ export function TournamentMatchBanner({
joinPool,
joinPass,
teams,
+ scoreSum,
} = useMatch();
+ if (!data.matchIsOver) {
+ preloadStageBanners(
+ (data.mapList ?? [])
+ .filter((map) => !map.bannedByTournamentTeamId)
+ .slice(scoreSum)
+ .map((map) => map.stageId),
+ );
+ }
+
const [teamOne, teamTwo] = teams;
const hostingTeam =
teamOne && teamTwo ? resolveHostingTeam([teamOne, teamTwo]) : null;
diff --git a/app/styles/utils.css b/app/styles/utils.css
index ae702b3f6..f7486f01d 100644
--- a/app/styles/utils.css
+++ b/app/styles/utils.css
@@ -542,5 +542,11 @@
::view-transition-new(*) {
animation: none;
}
+
+ /* opt-in for boundaries whose animation is a plain fade */
+ ::view-transition-old(*.reduced-motion-safe),
+ ::view-transition-new(*.reduced-motion-safe) {
+ animation: revert-layer;
+ }
}
}
diff --git a/changelog/2026-09-19-match-stage-banner-transition.md b/changelog/2026-09-19-match-stage-banner-transition.md
new file mode 100644
index 000000000..9dd5aa0cb
--- /dev/null
+++ b/changelog/2026-09-19-match-stage-banner-transition.md
@@ -0,0 +1,5 @@
+---
+navItem: [sendouq, medal]
+type: feature
+---
+Match page stage banner now crossfades to the next map instead of popping, with upcoming maps' images loaded ahead of time