diff --git a/src/app/router.tsx b/src/app/router.tsx
index 2ec5b70..5eaced4 100644
--- a/src/app/router.tsx
+++ b/src/app/router.tsx
@@ -4,7 +4,6 @@ import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query
import { routeTree } from "./routeTree.gen";
import { DefaultCatchBoundary } from "../components/DefaultCatchBoundary";
import RoutePending from "../components/route-pending";
-import { headerChange, navDirection } from "@/features/core/utils/nav-direction";
import { defaultHeaderConfig } from "@/features/core/hooks/use-router-config";
export function getRouter() {
@@ -34,12 +33,6 @@ export function getRouter() {
defaultPreloadStaleTime: 60_000,
defaultPendingMs: 100,
defaultPendingMinMs: 0,
- defaultViewTransition: {
- types: ({ fromLocation, toLocation }) => {
- const direction = navDirection(fromLocation, toLocation);
- return direction === "none" ? false : [direction];
- },
- },
defaultPendingComponent: RoutePending,
defaultErrorComponent: DefaultCatchBoundary,
});
@@ -49,13 +42,6 @@ export function getRouter() {
queryClient
})
- if (typeof document !== "undefined") {
- router.subscribe("onBeforeNavigate", ({ fromLocation, toLocation }) => {
- document.documentElement.dataset.navDirection = navDirection(fromLocation, toLocation);
- document.documentElement.dataset.headerVt = headerChange(fromLocation, toLocation);
- });
- }
-
return router;
}
diff --git a/src/app/styles.css b/src/app/styles.css
index f67483e..b032053 100644
--- a/src/app/styles.css
+++ b/src/app/styles.css
@@ -50,120 +50,13 @@ textarea,
transform: scale(0.90) translateY(-4px);
}
-/* keep the page tappable mid-transition (rapid tab switching) */
-::view-transition {
- pointer-events: none;
-}
-
-/* theme overlays must be this element, not body::before, or they blink off
- during transitions */
+/* themes paint their full-page texture through this element */
.flxn-theme-overlay {
position: fixed;
pointer-events: none;
- view-transition-name: theme-overlay;
-}
-
-/* pinned states, not 0ms durations: Safari skips zero-duration VT animations
- and blends both snapshots */
-::view-transition-old(root),
-::view-transition-old(app-header),
-::view-transition-old(theme-overlay) {
- animation: none;
- opacity: 0;
-}
-::view-transition-new(root),
-::view-transition-new(app-header),
-::view-transition-new(theme-overlay) {
- animation: none;
- opacity: 1;
- mix-blend-mode: normal;
-}
-::view-transition-image-pair(root),
-::view-transition-image-pair(app-header),
-::view-transition-image-pair(theme-overlay),
-::view-transition-image-pair(page) {
- isolation: auto;
-}
-
-::view-transition-old(page),
-::view-transition-new(page) {
- animation-duration: 180ms;
- animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
- mix-blend-mode: normal;
-}
-
-html[data-nav-direction="forward"]::view-transition-old(page) {
- animation: 250ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-out-left;
-}
-html[data-nav-direction="forward"]::view-transition-new(page) {
- animation: 250ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-in-right;
-}
-html[data-nav-direction="back"]::view-transition-old(page) {
- animation: 250ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-out-right;
-}
-html[data-nav-direction="back"]::view-transition-new(page) {
- animation: 250ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-in-left;
-}
-
-html[data-nav-direction="forward"][data-header-vt="exit"]::view-transition-old(app-header) {
- animation: 250ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-out-left;
- opacity: 1;
-}
-html[data-nav-direction="back"][data-header-vt="exit"]::view-transition-old(app-header) {
- animation: 250ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-out-right;
- opacity: 1;
-}
-html[data-nav-direction="forward"][data-header-vt="enter"]::view-transition-new(app-header) {
- animation: 250ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-in-right;
-}
-html[data-nav-direction="back"][data-header-vt="enter"]::view-transition-new(app-header) {
- animation: 250ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-in-left;
-}
-
-@keyframes flxn-page-out-left {
- to { transform: translateX(-100%); }
-}
-@keyframes flxn-page-in-right {
- from { transform: translateX(100%); }
-}
-@keyframes flxn-page-out-right {
- to { transform: translateX(100%); }
-}
-@keyframes flxn-page-in-left {
- from { transform: translateX(-100%); }
-}
-
-@keyframes flxn-vt-fade-out {
- to { opacity: 0; }
-}
-@keyframes flxn-vt-fade-in {
- from { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
- ::view-transition-group(*),
- ::view-transition-image-pair(*) {
- animation: none !important;
- }
-
- ::view-transition-old(*) {
- animation: 120ms ease both flxn-vt-fade-out !important;
- z-index: auto !important;
- }
-
- ::view-transition-new(*) {
- animation: 120ms ease both flxn-vt-fade-in !important;
- }
-
- ::view-transition-old(root),
- ::view-transition-old(app-header),
- ::view-transition-old(theme-overlay),
- ::view-transition-new(root),
- ::view-transition-new(app-header),
- ::view-transition-new(theme-overlay) {
- animation: none !important;
- }
-
.app,
[data-drawer-level] {
transition: none;
diff --git a/src/features/core/components/header.tsx b/src/features/core/components/header.tsx
index e5c3e47..5a62822 100644
--- a/src/features/core/components/header.tsx
+++ b/src/features/core/components/header.tsx
@@ -60,7 +60,6 @@ const Header = ({ collapsed, title, titleValues, withBackButton }: HeaderConfig)
alignItems: 'center',
justifyContent: 'center',
paddingTop: 'env(safe-area-inset-top, 0px)',
- viewTransitionName: 'app-header',
}}
>
{ withBackButton && }
diff --git a/src/features/core/components/layout.tsx b/src/features/core/components/layout.tsx
index b752d18..478e3fb 100644
--- a/src/features/core/components/layout.tsx
+++ b/src/features/core/components/layout.tsx
@@ -1,4 +1,4 @@
-import { AppShell, Box } from '@mantine/core';
+import { AppShell } from '@mantine/core';
import { PropsWithChildren } from 'react';
import Header from './header';
import Navbar from './navbar';
@@ -15,7 +15,7 @@ const Layout: React.FC = ({ children }) => {
= ({ children }) => {
maw='100dvw'
style={{ overflow: 'hidden' }}
>
- {/* pane box must be route-invariant or slides drift */}
-
-
-
- {children}
-
-
-
+
+
+ {children}
+
+
diff --git a/src/features/core/components/nav-indicator/use-nav-indicator.ts b/src/features/core/components/nav-indicator/use-nav-indicator.ts
new file mode 100644
index 0000000..b4a8e0b
--- /dev/null
+++ b/src/features/core/components/nav-indicator/use-nav-indicator.ts
@@ -0,0 +1,68 @@
+import { useLayoutEffect, useRef } from "react";
+import { useRouterState } from "@tanstack/react-router";
+
+const TRAVEL_MS = 333;
+
+export const useNavIndicator = (isMobile: boolean) => {
+ const containerRef = useRef(null);
+ const indicatorRef = useRef(null);
+ const shapeRef = useRef(null);
+ const pathname = useRouterState({ select: (s) => s.location.pathname });
+
+ useLayoutEffect(() => {
+ const container = containerRef.current;
+ const indicator = indicatorRef.current;
+ const shape = shapeRef.current;
+ if (!container || !indicator || !shape) return;
+
+ let last: number | null = null;
+
+ const place = () => {
+ const active = container.querySelector("[data-active]");
+ if (!active) {
+ delete indicator.dataset.placed;
+ last = null;
+ return;
+ }
+
+ const x = active.offsetLeft;
+ const y = active.offsetTop;
+ const travelled = isMobile ? x : y;
+ const first = indicator.dataset.placed !== "1";
+
+ if (first) indicator.style.transition = "none";
+ indicator.style.setProperty("--nav-x", `${x}px`);
+ indicator.style.setProperty("--nav-y", `${y}px`);
+ indicator.style.setProperty("--nav-w", `${active.offsetWidth}px`);
+ indicator.style.setProperty("--nav-h", `${active.offsetHeight}px`);
+ indicator.dataset.placed = "1";
+
+ if (first) {
+ indicator.getBoundingClientRect();
+ indicator.style.transition = "";
+ } else if (
+ last !== null &&
+ Math.abs(travelled - last) > 1 &&
+ !window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ ) {
+ const axis = isMobile ? "scaleX" : "scaleY";
+ shape.animate(
+ [
+ { transform: `${axis}(1)`, offset: 0 },
+ { transform: `${axis}(0.88)`, offset: 0.4 },
+ { transform: `${axis}(1)`, offset: 1 },
+ ],
+ { duration: TRAVEL_MS, easing: "ease-in-out" }
+ );
+ }
+ last = travelled;
+ };
+
+ place();
+ const observer = new ResizeObserver(place);
+ observer.observe(container);
+ return () => observer.disconnect();
+ }, [pathname, isMobile]);
+
+ return { containerRef, indicatorRef, shapeRef };
+};
diff --git a/src/features/core/components/nav-link/nav-link.tsx b/src/features/core/components/nav-link/nav-link.tsx
index 2a0c38b..f706cbb 100644
--- a/src/features/core/components/nav-link/nav-link.tsx
+++ b/src/features/core/components/nav-link/nav-link.tsx
@@ -32,30 +32,19 @@ export const NavLink = ({
-
-
+
+
{label}
diff --git a/src/features/core/components/navbar.tsx b/src/features/core/components/navbar.tsx
index 21f297d..e47ae1a 100644
--- a/src/features/core/components/navbar.tsx
+++ b/src/features/core/components/navbar.tsx
@@ -5,11 +5,13 @@ import { useAuth } from "@/contexts/auth-context";
import { useLinks } from "../hooks/use-links";
import { memo } from "react";
import { useIsPWA } from "@/hooks/use-is-pwa";
+import { useNavIndicator } from "./nav-indicator/use-nav-indicator";
const Navbar = () => {
const { user, roles } = useAuth()
const isMobile = useIsMobile();
const isPWA = useIsPWA();
+ const { containerRef, indicatorRef, shapeRef } = useNavIndicator(isMobile);
const links = useLinks(user?.id, roles);
@@ -17,9 +19,16 @@ const Navbar = () => {
? 'max(env(safe-area-inset-bottom, 0px), 0.5rem)'
: 'env(safe-area-inset-bottom, 0px)';
+ const indicator = (
+
+
+
+ );
+
if (isMobile) return (
-
-
+
+
+ {indicator}
{links.map((link) => (
))}
@@ -27,9 +36,10 @@ const Navbar = () => {
)
- return
+ return
-
+
+ {indicator}
{links.map((link) => (
))}
diff --git a/src/features/core/utils/nav-direction.ts b/src/features/core/utils/nav-direction.ts
deleted file mode 100644
index 7739e39..0000000
--- a/src/features/core/utils/nav-direction.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import type { ParsedLocation } from "@tanstack/react-router";
-
-export type NavDirection = "forward" | "back" | "none";
-
-const tabRank = (pathname: string): number | null => {
- if (pathname === "/") return 0;
- if (pathname.startsWith("/stats")) return 1;
- if (pathname.startsWith("/tournaments")) return 2;
- if (pathname.startsWith("/profile") || pathname.startsWith("/settings")) return 3;
- if (pathname.startsWith("/admin")) return 4;
- return null;
-};
-
-const depth = (pathname: string) => pathname.split("/").filter(Boolean).length;
-
-// Keep in sync with route loaders that set header.collapsed
-const headerHidden = (pathname: string) => {
- const segs = pathname.split("/").filter(Boolean);
- return segs.length === 2 && ["profile", "teams", "tournaments"].includes(segs[0]);
-};
-
-export function headerChange(
- from: ParsedLocation | undefined,
- to: ParsedLocation
-): "exit" | "enter" | "none" {
- if (!from) return "none";
- const fromHidden = headerHidden(from.pathname);
- const toHidden = headerHidden(to.pathname);
- if (fromHidden === toHidden) return "none";
- return toHidden ? "exit" : "enter";
-}
-
-export function navDirection(
- from: ParsedLocation | undefined,
- to: ParsedLocation
-): NavDirection {
- if (!from || from.pathname === to.pathname) return "none";
-
- const fromRank = tabRank(from.pathname);
- const toRank = tabRank(to.pathname);
- if (fromRank !== null && toRank !== null && fromRank !== toRank) {
- return toRank > fromRank ? "forward" : "back";
- }
-
- const fromIndex = from.state.__TSR_index;
- const toIndex = to.state.__TSR_index;
- if (Number.isFinite(fromIndex) && Number.isFinite(toIndex) && fromIndex !== toIndex) {
- return toIndex > fromIndex ? "forward" : "back";
- }
-
- return depth(to.pathname) >= depth(from.pathname) ? "forward" : "back";
-}
diff --git a/src/themes/base.css b/src/themes/base.css
index 4f71594..39d46bc 100644
--- a/src/themes/base.css
+++ b/src/themes/base.css
@@ -4,6 +4,10 @@
explicit [data-mantine-color-scheme] blocks. */
:root {
+ /* bottom-nav indicator travel: spring (stiffness 320, damping 30) */
+ --flxn-nav-travel: linear(0, 0.0102, 0.0376, 0.0778, 0.1272, 0.1829, 0.2422, 0.3033, 0.3646, 0.4247, 0.4829, 0.5383, 0.5906, 0.6393, 0.6843, 0.7256, 0.7631, 0.7969, 0.8273, 0.8543, 0.8783, 0.8993, 0.9176, 0.9335, 0.9472, 0.9589, 0.9688, 0.9772, 0.9841, 0.9898, 0.9945, 0.9982, 1.0011, 1.0034, 1.0051, 1.0063, 1.0072, 1.0077, 1.0079, 1.0079, 1.0078);
+ --flxn-nav-travel-ms: 333ms;
+
/* accolades */
--flxn-gold: gold;
--flxn-silver: var(--mantine-color-gray-5);
diff --git a/src/themes/hooks.css b/src/themes/hooks.css
index 21ad442..456f14f 100644
--- a/src/themes/hooks.css
+++ b/src/themes/hooks.css
@@ -67,3 +67,61 @@
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
}
+
+/* Bottom-nav active indicator: one element glides between tabs. The surface is
+ neutral on purpose — the accent belongs to the glyph, not a tint behind it. */
+.flxn-nav-indicator {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: var(--nav-w, 0);
+ height: var(--nav-h, 0);
+ translate: var(--nav-x, 0) var(--nav-y, 0);
+ pointer-events: none;
+ opacity: 0;
+ z-index: 0;
+ transition: translate var(--flxn-nav-travel-ms) var(--flxn-nav-travel),
+ width var(--flxn-nav-travel-ms) var(--flxn-nav-travel),
+ height var(--flxn-nav-travel-ms) var(--flxn-nav-travel),
+ opacity 140ms ease-out;
+}
+.flxn-nav-indicator[data-placed="1"] {
+ opacity: 1;
+}
+.flxn-nav-indicator-shape {
+ display: block;
+ width: calc(100% + var(--flxn-nav-pad-x, 12px));
+ height: calc(100% + var(--flxn-nav-pad-y, 6px));
+ margin: calc(var(--flxn-nav-pad-y, 6px) / -2) calc(var(--flxn-nav-pad-x, 12px) / -2);
+ background: var(--flxn-nav-surface, var(--mantine-color-default));
+ border: 1px solid var(--flxn-nav-border, var(--mantine-color-default-border));
+ border-radius: var(--flxn-nav-radius, var(--mantine-radius-md));
+ box-shadow: var(--mantine-shadow-xs);
+}
+.flxn-nav-link {
+ /* lifted above the indicator, or an opaque fill covers the glyph */
+ position: relative;
+ z-index: 1;
+}
+.flxn-nav-link svg,
+.flxn-nav-link p {
+ transition: color 140ms ease-out;
+}
+.flxn-nav-link[data-active] {
+ color: var(--flxn-nav-accent, var(--mantine-primary-color-filled));
+}
+@media (prefers-reduced-motion: no-preference) {
+ .flxn-nav-link[data-active] svg {
+ animation: flxn-nav-pop 320ms cubic-bezier(0.32, 0.72, 0, 1);
+ }
+}
+@keyframes flxn-nav-pop {
+ 0% { scale: 1; }
+ 38% { scale: 1.16; }
+ 100% { scale: 1; }
+}
+@media (prefers-reduced-motion: reduce) {
+ .flxn-nav-indicator {
+ transition: opacity 120ms ease-out;
+ }
+}
diff --git a/src/themes/sketch/theme.css b/src/themes/sketch/theme.css
index 5e21a6f..cf4b9ff 100644
--- a/src/themes/sketch/theme.css
+++ b/src/themes/sketch/theme.css
@@ -167,14 +167,6 @@
opacity: 0.07;
}
-[data-theme="sketch"]::view-transition-new(theme-overlay) {
- mix-blend-mode: multiply;
-}
-
-[data-theme="sketch"][data-mantine-color-scheme="dark"]::view-transition-new(theme-overlay) {
- mix-blend-mode: screen;
-}
-
/* ---------------------------------------------------------------- */
/* 4. Surfaces — every card its own doodle */
/* ---------------------------------------------------------------- */