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