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;
+38 -5
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,10 +37,32 @@ const formatItems = (items: CustomerOrderSummary['items']) =>
) )
.join(', ') .join(', ')
export const OrdersListView = ({ orders }: OrdersListViewProps) => ( export const OrdersListView = ({ orders }: OrdersListViewProps) => {
const [displayedOrders, setDisplayedOrders] = useState(orders)
const [isExiting, setIsExiting] = useState(false)
const hadOrders = useRef(orders.length > 0)
// Orders can go from populated to empty in one prop update (e.g. signing
// out clears them) — keep the previous list on screen just long enough to
// fade it out instead of popping straight to the empty state.
useEffect(() => {
if (orders.length === 0 && hadOrders.current) {
hadOrders.current = false
setIsExiting(true)
const timeout = setTimeout(() => {
setDisplayedOrders([])
setIsExiting(false)
}, EXIT_DURATION_MS)
return () => clearTimeout(timeout)
}
hadOrders.current = orders.length > 0
setDisplayedOrders(orders)
}, [orders])
return (
<section className={styles.page}> <section className={styles.page}>
<h1 className={styles.heading}>Your orders</h1> <h1 className={styles.heading}>Your orders</h1>
{orders.length === 0 ? ( {displayedOrders.length === 0 ? (
<div className={styles.empty}> <div className={styles.empty}>
<OrdersIcon /> <OrdersIcon />
<p className={styles.emptyTitle}>No orders yet</p> <p className={styles.emptyTitle}>No orders yet</p>
@@ -48,8 +75,11 @@ export const OrdersListView = ({ orders }: OrdersListViewProps) => (
</div> </div>
) : ( ) : (
<ul className={styles.orders}> <ul className={styles.orders}>
{orders.map((order) => ( {displayedOrders.map((order) => (
<li key={order.id}> <li
key={order.id}
className={isExiting ? styles.orderExiting : undefined}
>
<Link <Link
to="/orders/$orderId" to="/orders/$orderId"
params={{ orderId: String(order.id) }} params={{ orderId: String(order.id) }}
@@ -65,7 +95,9 @@ export const OrdersListView = ({ orders }: OrdersListViewProps) => (
<span className={styles.orderName}>Order #{order.id}</span> <span className={styles.orderName}>Order #{order.id}</span>
<span className={styles.orderStatus}>{order.status}</span> <span className={styles.orderStatus}>{order.status}</span>
</div> </div>
<p className={styles.orderItems}>{formatItems(order.items)}</p> <p className={styles.orderItems}>
{formatItems(order.items)}
</p>
<p className={styles.orderMeta}> <p className={styles.orderMeta}>
{order.createdAt.toLocaleDateString([], { {order.createdAt.toLocaleDateString([], {
month: 'short', month: 'short',
@@ -80,3 +112,4 @@ export const OrdersListView = ({ orders }: OrdersListViewProps) => (
)} )}
</section> </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} />