From d9979bbc8d083e0e622aa77f02926961b5918584 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Tue, 29 Sep 2026 12:37:39 +0200 Subject: [PATCH] feat(web): fade between pages with view transitions Pathname changes after the first load now run a typed `page` view transition: the old page fades out over 140ms and the new one fades up 6px over 200ms on the house tokens. Search-param and hash changes, the first load and browsers without view-transition types stay instant. The app nav, settings nav and auth logo are named so they hold still. The auth card's load-time entrance is removed. Applications views, the Documents grid/list switch and Calendar month changes now enter with a short fade (months slide from the direction of travel), only after the user switches. --- .../applications/components/calendar-view.tsx | 185 ++++++++++-------- apps/web/src/features/auth/layout.tsx | 4 +- .../src/features/documents/documents-page.tsx | 15 +- apps/web/src/index.css | 36 ++++ apps/web/src/router.tsx | 7 + .../dashboard/-components/app-shell.tsx | 6 +- .../routes/dashboard/applications/index.tsx | 20 +- .../src/routes/dashboard/settings/route.tsx | 2 +- 8 files changed, 180 insertions(+), 95 deletions(-) diff --git a/apps/web/src/features/applications/components/calendar-view.tsx b/apps/web/src/features/applications/components/calendar-view.tsx index 3e29bd22d..277dac95c 100644 --- a/apps/web/src/features/applications/components/calendar-view.tsx +++ b/apps/web/src/features/applications/components/calendar-view.tsx @@ -42,6 +42,8 @@ export function ApplicationCalendar({ applications, allApplications, onOpen }: A const now = new Date(); return new Date(now.getFullYear(), now.getMonth(), 1); }); + // Which way the last month change went: the new month's days slide in from that side. 0 = no change yet. + const [direction, setDirection] = useState<-1 | 0 | 1>(0); const [dialog, setDialog] = useState({ open: false }); const interviews = collectInterviews(applications); @@ -63,7 +65,11 @@ export function ApplicationCalendar({ applications, allApplications, onOpen }: A const timeFormat = new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit" }); const dayHeadingFormat = new Intl.DateTimeFormat(locale, { weekday: "long", month: "long", day: "numeric" }); - const shiftMonth = (delta: number) => setMonth((prev) => new Date(prev.getFullYear(), prev.getMonth() + delta, 1)); + const goToMonth = (next: Date) => { + setDirection(next > month ? 1 : -1); + setMonth(next); + }; + const shiftMonth = (delta: number) => goToMonth(new Date(month.getFullYear(), month.getMonth() + delta, 1)); const schedule = (day: Date | null = null) => setDialog({ open: true, application: null, interview: null, day }); const edit = (item: ScheduledInterview) => setDialog({ open: true, application: item.application, interview: item.interview, day: null }); @@ -100,7 +106,7 @@ export function ApplicationCalendar({ applications, allApplications, onOpen }: A size="sm" variant="secondary" disabled={isCurrentMonth} - onClick={() => setMonth(new Date(today.getFullYear(), today.getMonth(), 1))} + onClick={() => goToMonth(new Date(today.getFullYear(), today.getMonth(), 1))} > This month @@ -114,91 +120,102 @@ export function ApplicationCalendar({ applications, allApplications, onOpen }: A -
- {days.slice(0, 7).map((day) => ( -
- {weekdayFormat.format(day)} -
- ))} - - {days.map((day, i) => { - const key = dayKey(day); - const items = byDay.get(key) ?? []; - const inMonth = day.getMonth() === month.getMonth(); - const isToday = key === todayKey; - const extra = items.length - MAX_CHIPS_PER_DAY; - const date = dayHeadingFormat.format(day); - const scheduleLabel = t`Schedule an interview on ${date}`; - return ( +
+
+ {days.slice(0, 7).map((day) => (
-
- - - {day.getDate()} - -
- {items.slice(0, MAX_CHIPS_PER_DAY).map((item) => ( - edit(item)} - /> - ))} - {extra > 0 && ( - - - } - > - +{extra} more - - -

{dayHeading(day)}

- {items.map((item) => ( - edit(item)} - /> - ))} -
-
- )} + {weekdayFormat.format(day)}
- ); - })} + ))} +
+
+ {days.map((day, i) => { + const key = dayKey(day); + const items = byDay.get(key) ?? []; + const inMonth = day.getMonth() === month.getMonth(); + const isToday = key === todayKey; + const extra = items.length - MAX_CHIPS_PER_DAY; + const date = dayHeadingFormat.format(day); + const scheduleLabel = t`Schedule an interview on ${date}`; + return ( +
+
+ + + {day.getDate()} + +
+ {items.slice(0, MAX_CHIPS_PER_DAY).map((item) => ( + edit(item)} + /> + ))} + {extra > 0 && ( + + + } + > + +{extra} more + + +

{dayHeading(day)}

+ {items.map((item) => ( + edit(item)} + /> + ))} +
+
+ )} +
+ ); + })} +
diff --git a/apps/web/src/features/auth/layout.tsx b/apps/web/src/features/auth/layout.tsx index c247eebc4..7ed6c7c87 100644 --- a/apps/web/src/features/auth/layout.tsx +++ b/apps/web/src/features/auth/layout.tsx @@ -3,8 +3,8 @@ import { BrandIcon } from "@reactive-resume/ui/components/brand-icon"; export function AuthLayout() { return ( -
- +
+
diff --git a/apps/web/src/features/documents/documents-page.tsx b/apps/web/src/features/documents/documents-page.tsx index dcd49376e..f46ad48e5 100644 --- a/apps/web/src/features/documents/documents-page.tsx +++ b/apps/web/src/features/documents/documents-page.tsx @@ -41,12 +41,18 @@ export function DocumentsPage({ search, onSearchChange }: DocumentsPageProps) { const searchRef = useRef(null); const [tagsFor, setTagsFor] = useState(null); const [linkFor, setLinkFor] = useState(null); + // Grid and list animate in only after a switch, never on the page's first render. + const [viewSwitched, setViewSwitched] = useState(false); const { data: documents, isPending } = useQuery(orpc.documents.list.queryOptions({ input: { trashed: false } })); const view = search.view ?? readStoredView(); const setView = (next: "grid" | "list") => { + setViewSwitched(true); storeView(next); onSearchChange({ view: next }); }; + const viewEnter = + viewSwitched && + "transition-[opacity,translate] duration-standard ease-enter starting:translate-y-1 starting:opacity-0"; useHotkey("/", (event) => { if (isEditableElementFocused()) return; @@ -200,7 +206,7 @@ export function DocumentsPage({ search, onSearchChange }: DocumentsPageProps) { )}
) : view === "list" ? ( - +
@@ -229,7 +235,12 @@ export function DocumentsPage({ search, onSearchChange }: DocumentsPageProps) {
) : ( -
+
{shown.map((document) => ( ))} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 85eda7154..50fe4f087 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -19,3 +19,39 @@ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='6' height='4' viewBox='0 0 6 4'%3E%3Cpath d='M0 3 Q1.5 0 3 3 T6 3' fill='none' stroke='black' stroke-width='1.2'/%3E%3C/svg%3E") repeat-x left center / 6px 4px; } + +/* + * Page transitions: the router starts a view transition of type `page` when the pathname changes. The page fades + * out quickly and the new one fades up 6px; named navigation (app-nav, settings-nav, auth-brand) stays in place. + */ +@keyframes vt-page-out { + to { + opacity: 0; + } +} + +@keyframes vt-page-in { + from { + opacity: 0; + translate: 0 6px; + } +} + +html:active-view-transition-type(page)::view-transition { + background-color: var(--bg); + pointer-events: none; +} + +html:active-view-transition-type(page)::view-transition-group(*) { + animation-duration: var(--d2); + animation-timing-function: var(--ease); +} + +html:active-view-transition-type(page)::view-transition-old(root), +html:active-view-transition-type(page)::view-transition-old(*):only-child { + animation: calc(var(--d2) * 0.7) var(--ease) both vt-page-out; +} + +html:active-view-transition-type(page)::view-transition-new(root) { + animation: var(--d2) var(--ease) both vt-page-in; +} diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index f70329291..12dd2d6ec 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -7,6 +7,10 @@ import { getQueryClient } from "./libs/query/client"; import { loadRootContext } from "./libs/root-context"; import { routeTree } from "./routeTree.gen"; +// A pathname change fades the page (styles in index.css). Search-param and hash changes (filters, views, editor +// modes), the first load, and browsers without view-transition types swap instantly. +const supportsTransitionTypes = globalThis.CSS?.supports?.("selector(:active-view-transition-type(a))") === true; + export const getRouter = async () => { const queryClient = getQueryClient(); @@ -16,6 +20,9 @@ export const getRouter = async () => { routeTree, scrollRestoration: true, defaultStructuralSharing: true, + defaultViewTransition: supportsTransitionTypes && { + types: ({ fromLocation, pathChanged }) => (fromLocation && pathChanged ? ["page"] : false), + }, // Hovering a link starts its loaders; TanStack Query decides freshness, not the router's preload cache. defaultPreload: "intent", defaultPreloadStaleTime: 0, diff --git a/apps/web/src/routes/dashboard/-components/app-shell.tsx b/apps/web/src/routes/dashboard/-components/app-shell.tsx index 2c5a566c9..a0cfc4c67 100644 --- a/apps/web/src/routes/dashboard/-components/app-shell.tsx +++ b/apps/web/src/routes/dashboard/-components/app-shell.tsx @@ -110,7 +110,7 @@ function Sidebar() { const openDialog = useDialogStore((state) => state.openDialog); return ( -