Animate orders out with a CSS transition instead of the View Transitions API

The prior attempt wrapped the sign-out router.invalidate() in
document.startViewTransition(), but that had two problems: invalidate()
does its own network refetch inside the transition's update callback,
which freezes the page for the round-trip instead of animating
anything, and TanStack Router's invalidate() explicitly opts itself out
of view transitions (sets shouldViewTransition = false) for exactly
that reason. On top of that, OrdersRoute briefly returns null between
the account loader clearing and the guest-orders lookup resolving,
unmounting OrdersListView and wiping any exit-animation state anyway.

Replace it with a plain, self-contained approach:
- OrdersRoute now keeps rendering the last known non-null orders list
  through that brief gap (a lastKnownOrders ref) instead of unmounting
  OrdersListView, so its local animation state survives.
- OrdersListView holds onto the previous non-empty list for
  EXIT_DURATION_MS after it goes empty, applying an `orderExiting`
  class that fades/slides each card out via a plain CSS transition,
  then swaps to the actual empty state once the transition finishes.

This has no dependency on browser view-transition support, network
timing, or router internals.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-13 20:03:33 +02:00
co-authored by Claude Sonnet 5
parent 8d7f99848c
commit 5f375a0966
4 changed files with 101 additions and 71 deletions
@@ -71,13 +71,21 @@
background: $color-surface; background: $color-surface;
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
transition: border-color 0.15s ease; transition:
border-color 0.15s ease,
opacity 0.28s ease,
transform 0.28s ease;
&:hover { &:hover {
border-color: $color-primary; border-color: $color-primary;
} }
} }
.orderExiting .order {
opacity: 0;
transform: translateY(-8px) scale(0.98);
}
.orderHeader { .orderHeader {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
+80 -47
View File
@@ -1,4 +1,5 @@
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { useEffect, useRef, useState } from 'react'
import type { CustomerOrderSummary } from '#/domain/ordering.ts' import type { CustomerOrderSummary } from '#/domain/ordering.ts'
import { formatPrice } from '#/lib/format.ts' import { formatPrice } from '#/lib/format.ts'
@@ -9,6 +10,10 @@ type OrdersListViewProps = {
orders: Array<CustomerOrderSummary> orders: Array<CustomerOrderSummary>
} }
// Must match the CSS transition duration on .order in
// OrdersListView.module.scss.
const EXIT_DURATION_MS = 280
const OrdersIcon = () => ( const OrdersIcon = () => (
<svg <svg
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -32,51 +37,79 @@ const formatItems = (items: CustomerOrderSummary['items']) =>
) )
.join(', ') .join(', ')
export const OrdersListView = ({ orders }: OrdersListViewProps) => ( export const OrdersListView = ({ orders }: OrdersListViewProps) => {
<section className={styles.page}> const [displayedOrders, setDisplayedOrders] = useState(orders)
<h1 className={styles.heading}>Your orders</h1> const [isExiting, setIsExiting] = useState(false)
{orders.length === 0 ? ( const hadOrders = useRef(orders.length > 0)
<div className={styles.empty}>
<OrdersIcon /> // Orders can go from populated to empty in one prop update (e.g. signing
<p className={styles.emptyTitle}>No orders yet</p> // out clears them) — keep the previous list on screen just long enough to
<p className={styles.emptyText}> // fade it out instead of popping straight to the empty state.
Your placed orders will show up here. useEffect(() => {
</p> if (orders.length === 0 && hadOrders.current) {
<Link to="/" className={styles.emptyCta}> hadOrders.current = false
Browse the menu setIsExiting(true)
</Link> const timeout = setTimeout(() => {
</div> setDisplayedOrders([])
) : ( setIsExiting(false)
<ul className={styles.orders}> }, EXIT_DURATION_MS)
{orders.map((order) => ( return () => clearTimeout(timeout)
<li key={order.id}> }
<Link hadOrders.current = orders.length > 0
to="/orders/$orderId" setDisplayedOrders(orders)
params={{ orderId: String(order.id) }} }, [orders])
search={
order.guestAccessToken return (
? { token: order.guestAccessToken } <section className={styles.page}>
: undefined <h1 className={styles.heading}>Your orders</h1>
} {displayedOrders.length === 0 ? (
className={styles.order} <div className={styles.empty}>
style={{ viewTransitionName: `order-card-${order.id}` }} <OrdersIcon />
<p className={styles.emptyTitle}>No orders yet</p>
<p className={styles.emptyText}>
Your placed orders will show up here.
</p>
<Link to="/" className={styles.emptyCta}>
Browse the menu
</Link>
</div>
) : (
<ul className={styles.orders}>
{displayedOrders.map((order) => (
<li
key={order.id}
className={isExiting ? styles.orderExiting : undefined}
> >
<div className={styles.orderHeader}> <Link
<span className={styles.orderName}>Order #{order.id}</span> to="/orders/$orderId"
<span className={styles.orderStatus}>{order.status}</span> params={{ orderId: String(order.id) }}
</div> search={
<p className={styles.orderItems}>{formatItems(order.items)}</p> order.guestAccessToken
<p className={styles.orderMeta}> ? { token: order.guestAccessToken }
{order.createdAt.toLocaleDateString([], { : undefined
month: 'short', }
day: 'numeric', className={styles.order}
})}{' '} style={{ viewTransitionName: `order-card-${order.id}` }}
· {formatPrice(order.totalCents)} >
</p> <div className={styles.orderHeader}>
</Link> <span className={styles.orderName}>Order #{order.id}</span>
</li> <span className={styles.orderStatus}>{order.status}</span>
))} </div>
</ul> <p className={styles.orderItems}>
)} {formatItems(order.items)}
</section> </p>
) <p className={styles.orderMeta}>
{order.createdAt.toLocaleDateString([], {
month: 'short',
day: 'numeric',
})}{' '}
· {formatPrice(order.totalCents)}
</p>
</Link>
</li>
))}
</ul>
)}
</section>
)
}
+1 -21
View File
@@ -153,27 +153,7 @@ const BetterAuthHeader = () => {
clearBasket() clearBasket()
void authClient.signOut({ void authClient.signOut({
fetchOptions: { fetchOptions: {
onSuccess: () => { onSuccess: () => void router.invalidate(),
// Cross-fades the now-cleared Orders list out (and
// the empty state in) instead of popping instantly —
// each order card already carries its own
// `view-transition-name` (see OrdersListView), so
// this reuses that for a proper per-card exit.
if ('startViewTransition' in document) {
// Rejects if the browser can't actually run the
// transition right now (e.g. the tab isn't
// visible) — router.invalidate() still runs either
// way, so there's nothing to handle beyond not
// leaving this as an unhandled rejection.
const transition = document.startViewTransition(() =>
router.invalidate(),
)
transition.ready.catch(() => {})
transition.finished.catch(() => {})
} else {
void router.invalidate()
}
},
}, },
}) })
}} }}
+11 -2
View File
@@ -1,5 +1,5 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { OrdersListView } from '#/components/orders/OrdersListView.tsx' import { OrdersListView } from '#/components/orders/OrdersListView.tsx'
import type { CustomerOrderSummary } from '#/domain/ordering.ts' import type { CustomerOrderSummary } from '#/domain/ordering.ts'
@@ -25,7 +25,16 @@ const OrdersRoute = () => {
void getGuestOrders({ data: { refs } }).then(setGuestOrders) void getGuestOrders({ data: { refs } }).then(setGuestOrders)
}, [accountOrders]) }, [accountOrders])
const orders = accountOrders ?? guestOrders const resolvedOrders = accountOrders ?? guestOrders
// Signing out clears both sources for a moment (the account loader
// reruns and comes back null, guest refs were just wiped) before the
// guest lookup above resolves to `[]` — keep showing the last known
// list through that gap instead of unmounting OrdersListView, so its
// own exit animation (see OrdersListView) can actually play.
const lastKnownOrders = useRef<Array<CustomerOrderSummary> | null>(null)
if (resolvedOrders !== null) lastKnownOrders.current = resolvedOrders
const orders = resolvedOrders ?? lastKnownOrders.current
if (orders === null) return null if (orders === null) return null
return <OrdersListView orders={orders} /> return <OrdersListView orders={orders} />