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.
This commit is contained in:
Amruth Pillai
2026-09-29 12:38:03 +02:00
parent 500cabaf4a
commit d9979bbc8d
8 changed files with 180 additions and 95 deletions
@@ -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<DialogState>({ 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))}
>
<Trans>This month</Trans>
</Button>
@@ -114,91 +120,102 @@ export function ApplicationCalendar({ applications, allApplications, onOpen }: A
</div>
</div>
<div className="grid shrink-0 grid-cols-7 overflow-hidden rounded-xl border border-line bg-surface">
{days.slice(0, 7).map((day) => (
<div
key={`weekday-${day.getDay()}`}
className="border-line border-b px-2 py-2 text-center font-medium text-[11px] text-ink-3 uppercase tracking-wide"
>
{weekdayFormat.format(day)}
</div>
))}
{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 (
<div className="shrink-0 overflow-hidden rounded-xl border border-line bg-surface">
<div className="grid grid-cols-7">
{days.slice(0, 7).map((day) => (
<div
key={key}
data-day={key}
className={cn(
"group/day relative flex min-h-16 flex-col gap-1 border-line p-1 sm:min-h-24 sm:p-1.5",
i % 7 !== 6 && "border-e",
i < 35 && "border-b",
!inMonth && "bg-sunken/30",
)}
key={`weekday-${day.getDay()}`}
className="border-line border-b px-2 py-2 text-center font-medium text-[11px] text-ink-3 uppercase tracking-wide"
>
<div className="flex items-center justify-between">
<button
type="button"
title={scheduleLabel}
aria-label={scheduleLabel}
className="flex size-6 items-center justify-center rounded-md text-ink-3 opacity-0 transition-opacity hover:bg-sunken hover:text-ink focus-visible:opacity-100 group-hover/day:opacity-100 max-sm:hidden"
onClick={() => schedule(day)}
>
<Icon name="add" size={14} />
</button>
<span
className={cn(
"flex size-6 items-center justify-center rounded-full text-xs",
!inMonth && "text-ink-3/60",
isToday && "bg-accent font-semibold text-on-accent",
)}
>
{day.getDate()}
</span>
</div>
{items.slice(0, MAX_CHIPS_PER_DAY).map((item) => (
<InterviewChip
key={item.interview.id}
item={item}
time={timeFormat.format(item.start)}
onOpen={() => edit(item)}
/>
))}
{extra > 0 && (
<Popover>
<PopoverTrigger
render={
<button
type="button"
className="self-start rounded-md px-1.5 py-0.5 text-[11px] text-ink-3 transition-colors hover:bg-sunken hover:text-ink"
/>
}
>
<Trans>+{extra} more</Trans>
</PopoverTrigger>
<PopoverContent align="start" className="flex w-60 flex-col gap-1 p-2">
<p className="px-1 pb-1 font-medium text-xs">{dayHeading(day)}</p>
{items.map((item) => (
<InterviewChip
key={item.interview.id}
item={item}
time={timeFormat.format(item.start)}
onOpen={() => edit(item)}
/>
))}
</PopoverContent>
</Popover>
)}
{weekdayFormat.format(day)}
</div>
);
})}
))}
</div>
<div
key={`${month.getFullYear()}-${month.getMonth()}`}
className={cn(
"grid grid-cols-7",
direction !== 0 && "starting:opacity-0 transition-[opacity,translate] duration-standard ease-enter",
direction === 1 && "starting:translate-x-2 rtl:starting:-translate-x-2",
direction === -1 && "starting:-translate-x-2 rtl:starting:translate-x-2",
)}
>
{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 (
<div
key={key}
data-day={key}
className={cn(
"group/day relative flex min-h-16 flex-col gap-1 border-line p-1 sm:min-h-24 sm:p-1.5",
i % 7 !== 6 && "border-e",
i < 35 && "border-b",
!inMonth && "bg-sunken/30",
)}
>
<div className="flex items-center justify-between">
<button
type="button"
title={scheduleLabel}
aria-label={scheduleLabel}
className="flex size-6 items-center justify-center rounded-md text-ink-3 opacity-0 transition-opacity hover:bg-sunken hover:text-ink focus-visible:opacity-100 group-hover/day:opacity-100 max-sm:hidden"
onClick={() => schedule(day)}
>
<Icon name="add" size={14} />
</button>
<span
className={cn(
"flex size-6 items-center justify-center rounded-full text-xs",
!inMonth && "text-ink-3/60",
isToday && "bg-accent font-semibold text-on-accent",
)}
>
{day.getDate()}
</span>
</div>
{items.slice(0, MAX_CHIPS_PER_DAY).map((item) => (
<InterviewChip
key={item.interview.id}
item={item}
time={timeFormat.format(item.start)}
onOpen={() => edit(item)}
/>
))}
{extra > 0 && (
<Popover>
<PopoverTrigger
render={
<button
type="button"
className="self-start rounded-md px-1.5 py-0.5 text-[11px] text-ink-3 transition-colors hover:bg-sunken hover:text-ink"
/>
}
>
<Trans>+{extra} more</Trans>
</PopoverTrigger>
<PopoverContent align="start" className="flex w-60 flex-col gap-1 p-2">
<p className="px-1 pb-1 font-medium text-xs">{dayHeading(day)}</p>
{items.map((item) => (
<InterviewChip
key={item.interview.id}
item={item}
time={timeFormat.format(item.start)}
onOpen={() => edit(item)}
/>
))}
</PopoverContent>
</Popover>
)}
</div>
);
})}
</div>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-ink-3 text-xs">
+2 -2
View File
@@ -3,8 +3,8 @@ import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
export function AuthLayout() {
return (
<div className="fade-in-0 slide-in-from-top-4 mx-auto flex h-svh w-dvw max-w-sm animate-in flex-col justify-center gap-y-6 px-4 xs:px-0 duration-emphasized ease-enter">
<BrandIcon className="mb-4 size-20 self-center" />
<div className="mx-auto flex h-svh w-dvw max-w-sm flex-col justify-center gap-y-6 px-4 xs:px-0">
<BrandIcon className="mb-4 size-20 self-center [view-transition-name:auth-brand]" />
<Outlet />
</div>
@@ -41,12 +41,18 @@ export function DocumentsPage({ search, onSearchChange }: DocumentsPageProps) {
const searchRef = useRef<HTMLInputElement>(null);
const [tagsFor, setTagsFor] = useState<DocumentSummary | null>(null);
const [linkFor, setLinkFor] = useState<DocumentSummary | null>(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) {
)}
</div>
) : view === "list" ? (
<table className="w-full border-collapse">
<table className={cn("w-full border-collapse", viewEnter)}>
<thead>
<tr className="border-line border-b text-start font-medium text-ink-3 text-xs">
<th className="h-10 ps-3 text-start font-medium">
@@ -229,7 +235,12 @@ export function DocumentsPage({ search, onSearchChange }: DocumentsPageProps) {
</tbody>
</table>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,204px))] gap-x-7 gap-y-[22px] max-sm:grid-cols-2 max-sm:gap-4">
<div
className={cn(
"grid grid-cols-[repeat(auto-fill,minmax(180px,204px))] gap-x-7 gap-y-[22px] max-sm:grid-cols-2 max-sm:gap-4",
viewEnter,
)}
>
{shown.map((document) => (
<DocumentCard key={`${document.type}:${document.id}`} document={document} {...itemProps} />
))}
+36
View File
@@ -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;
}
+7
View File
@@ -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,
@@ -110,7 +110,7 @@ function Sidebar() {
const openDialog = useDialogStore((state) => state.openDialog);
return (
<aside className="sticky top-0 flex h-svh flex-col gap-3 border-line border-e bg-surface p-3">
<aside className="sticky top-0 flex h-svh flex-col gap-3 border-line border-e bg-surface p-3 [view-transition-name:app-nav]">
<Link to="/" className="flex h-9 items-center gap-2.5 px-1.5">
<span className="grid size-6 place-items-center rounded-[5px] bg-ink font-display font-medium text-[13px] text-bg">
Rr
@@ -201,7 +201,7 @@ function Rail() {
const openDialog = useDialogStore((state) => state.openDialog);
return (
<aside className="sticky top-0 flex h-svh flex-col items-center gap-2 border-line border-e bg-surface py-3">
<aside className="sticky top-0 flex h-svh flex-col items-center gap-2 border-line border-e bg-surface py-3 [view-transition-name:app-nav]">
<Link
to="/"
aria-label="Reactive Resume"
@@ -297,7 +297,7 @@ function MobileTabs() {
return (
<nav
aria-label={t`App`}
className="sticky bottom-0 z-30 grid grid-cols-4 border-line border-t bg-surface pb-[env(safe-area-inset-bottom)]"
className="sticky bottom-0 z-30 grid grid-cols-4 border-line border-t bg-surface pb-[env(safe-area-inset-bottom)] [view-transition-name:app-nav]"
>
<Link
to="/dashboard"
@@ -75,6 +75,8 @@ function RouteComponent() {
const [exporting, setExporting] = useState(false);
const [editing, setEditing] = useState<Application | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
// Views animate in only after a switch, never on the page's first render.
const [viewSwitched, setViewSwitched] = useState(false);
const { data: applications, isPending } = useQuery(applicationsListQueryOptions());
const setSearch = (patch: Partial<Search>) => void navigate({ search: (prev: Search) => ({ ...prev, ...patch }) });
@@ -135,7 +137,13 @@ function RouteComponent() {
) : (
<>
<div className="flex flex-wrap items-center gap-2">
<Tabs value={shown} onValueChange={(value) => setSearch({ view: value as View })}>
<Tabs
value={shown}
onValueChange={(value) => {
setViewSwitched(true);
setSearch({ view: value as View });
}}
>
<TabsList aria-label={t`View`}>
<TabsTrigger value="list">
<Icon name="view_agenda" size={18} />
@@ -199,7 +207,13 @@ function RouteComponent() {
</Button>
</div>
) : (
<>
<div
key={shown}
className={cn(
viewSwitched &&
"starting:translate-y-1 starting:opacity-0 transition-[opacity,translate] duration-standard ease-enter",
)}
>
{shown === "list" && (
<ApplicationList
applications={filtered}
@@ -225,7 +239,7 @@ function RouteComponent() {
onOpen={(application) => setSelectedId(application.id)}
/>
)}
</>
</div>
)}
</>
)}
@@ -27,7 +27,7 @@ function RouteComponent() {
<div className="grid min-h-full content-start lg:grid-cols-[220px_minmax(0,1fr)] lg:content-stretch">
<nav
aria-label={t`Settings`}
className="flex flex-col gap-1 border-line max-sm:hidden max-lg:border-b lg:border-e lg:py-7 lg:ps-6 lg:pe-3"
className="flex flex-col gap-1 border-line [view-transition-name:settings-nav] max-sm:hidden max-lg:border-b lg:border-e lg:py-7 lg:ps-6 lg:pe-3"
>
<h1 className="ms-2 mb-3.5 font-display font-medium text-[26px] leading-8 max-lg:hidden">
<Trans>Settings</Trans>