Skip to content

Commit c595331

Browse files
authored
View Transitions (#554)
* Implement support for `aroundNav` handler. * Support view transitions in the magazin example. * Update router.d.ts * Transition product image. * Add View Transitions to docs. * Try to hack size limit action to use Bun, not npm. * wouter-preact types are up to date.
1 parent e36f8d5 commit c595331

File tree

20 files changed

+453
-15
lines changed

20 files changed

+453
-15
lines changed

.github/workflows/size.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ jobs:
1414
run: bun install --frozen-lockfile
1515
- name: Prepare wouter-preact (copy source files)
1616
run: cd packages/wouter-preact && npm run prepublishOnly
17+
- name: Symlink npm to bun
18+
run: |
19+
sudo ln -sf $(which bun) /usr/local/bin/npm
20+
sudo ln -sf $(which bunx) /usr/local/bin/npx
1721
- uses: andresz1/size-limit-action@v1
1822
with:
1923
github_token: ${{ secrets.GITHUB_TOKEN }}

README.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ projects that use wouter: **[Ultra](https://ultrajs.dev/)**,
8080
- [Can I initiate navigation from outside a component?](#can-i-initiate-navigation-from-outside-a-component)
8181
- [Can I use _wouter_ in my TypeScript project?](#can-i-use-wouter-in-my-typescript-project)
8282
- [How can add animated route transitions?](#how-can-add-animated-route-transitions)
83+
- [How do I add view transitions to my app?](#how-do-i-add-view-transitions-to-my-app)
8384
- [Preact support?](#preact-support)
8485
- [Server-side Rendering support (SSR)?](#server-side-rendering-support-ssr)
8586
- [How do I configure the router to render a specific route in tests?](#how-do-i-configure-the-router-to-render-a-specific-route-in-tests)
@@ -630,6 +631,16 @@ available options:
630631

631632
- `hrefs: (href: boolean) => string` — a function for transforming `href` attribute of an `<a />` element rendered by `Link`. It is used to support hash-based routing. By default, `href` attribute is the same as the `href` or `to` prop of a `Link`. A location hook can also define a `hook.hrefs` property, in this case the `href` will be inferred.
632633

634+
- **`aroundNav: (navigate, to, options) => void`** — a handler that wraps all navigation calls. Use this to intercept navigation and perform custom logic before and after the navigation occurs. You can modify navigation parameters, add side effects, or prevent navigation entirely. This is particularly useful for implementing [view transitions](#how-do-i-add-view-transitions-to-my-app). By default, it simply calls `navigate(to, options)`.
635+
636+
```js
637+
const aroundNav = (navigate, to, options) => {
638+
// do something before navigation
639+
navigate(to, options); // perform navigation
640+
// do something after navigation
641+
};
642+
```
643+
633644
## FAQ and Code Recipes
634645

635646
### I deploy my app to the subfolder. Can I specify a base path?
@@ -837,6 +848,64 @@ export const MyComponent = ({ isVisible }) => {
837848

838849
More complex examples involve using `useRoutes` hook (similar to how React Router does it), but wouter does not ship it out-of-the-box. Please refer to [this issue](https://github.com/molefrog/wouter/issues/414#issuecomment-1954192679) for the workaround.
839850

851+
### How do I use wouter with View Transitions API?
852+
853+
Wouter works seamlessly with the [View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API), but you'll need to manually activate it. This is because view transitions require synchronous DOM rendering and must be wrapped in `flushSync` from `react-dom`. Following wouter's philosophy of staying lightweight and avoiding unnecessary dependencies, view transitions aren't built-in. However, there's a simple escape hatch to enable them: the `aroundNav` prop.
854+
855+
```jsx
856+
import { flushSync } from "react-dom";
857+
import { Router, type AroundNavHandler } from "wouter";
858+
859+
const aroundNav: AroundNavHandler = (navigate, to, options) => {
860+
// Check if View Transitions API is supported
861+
if (!document.startViewTransition) {
862+
navigate(to, options);
863+
return;
864+
}
865+
866+
document.startViewTransition(() => {
867+
flushSync(() => {
868+
navigate(to, options);
869+
});
870+
});
871+
};
872+
873+
const App = () => (
874+
<Router aroundNav={aroundNav}>
875+
{/* Your routes here */}
876+
</Router>
877+
);
878+
```
879+
880+
You can also enable transitions selectively using the `transition` prop, which will be available in the `options` parameter:
881+
882+
```jsx
883+
// Enable transition for a specific link
884+
<Link to="/about" transition>About</Link>
885+
886+
// Or programmatically
887+
const [location, navigate] = useLocation();
888+
navigate("/about", { transition: true });
889+
890+
// Then check for it in your handler
891+
const aroundNav: AroundNavHandler = (navigate, to, options) => {
892+
if (!document.startViewTransition) {
893+
navigate(to, options);
894+
return;
895+
}
896+
897+
if (options?.transition) {
898+
document.startViewTransition(() => {
899+
flushSync(() => {
900+
navigate(to, options);
901+
});
902+
});
903+
} else {
904+
navigate(to, options);
905+
}
906+
};
907+
```
908+
840909
### Preact support?
841910
842911
Preact exports are available through a separate package named `wouter-preact` (or within the

packages/magazin/client.tsx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,33 @@
11
import { hydrateRoot } from "react-dom/client";
2-
import { Router } from "wouter";
2+
import { flushSync } from "react-dom";
3+
import { Router, type NavigateOptions, type AroundNavHandler } from "wouter";
34
import { HelmetProvider } from "@dr.pogodin/react-helmet";
45
import { App } from "./App";
56

7+
// Enable view transitions for navigation
8+
const aroundNav: AroundNavHandler = (navigate, to, options) => {
9+
// Feature detection for browsers that don't support View Transitions
10+
if (!document.startViewTransition) {
11+
navigate(to, options);
12+
return;
13+
}
14+
15+
// Only use view transitions if explicitly requested
16+
if (options?.transition) {
17+
document.startViewTransition(() => {
18+
flushSync(() => {
19+
navigate(to, options);
20+
});
21+
});
22+
} else {
23+
navigate(to, options);
24+
}
25+
};
26+
627
hydrateRoot(
728
document.body,
829
<HelmetProvider>
9-
<Router>
30+
<Router aroundNav={aroundNav}>
1031
<App />
1132
</Router>
1233
</HelmetProvider>

packages/magazin/components/navbar.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ function NavLink({
1414
return (
1515
<Link
1616
href={href}
17+
transition
1718
className={(active) =>
1819
`text-sm font-medium ${
1920
active ? "text-gray-900" : "text-gray-500 hover:text-gray-900"
@@ -31,6 +32,7 @@ export function Navbar() {
3132
<div className="max-w-4xl mx-auto flex items-center justify-between px-6">
3233
<Link
3334
href="/"
35+
transition
3436
className="flex items-center gap-2 hover:bg-neutral-200/50 rounded-md p-1"
3537
>
3638
<Logo />
@@ -43,6 +45,7 @@ export function Navbar() {
4345

4446
<Link
4547
href="/cart"
48+
transition
4649
className="relative flex items-center hover:bg-neutral-200/50 rounded-md p-1"
4750
>
4851
<i className="iconoir-cart text-xl" />

packages/magazin/components/with-status-code.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,8 @@ export function WithStatusCode({
1010
const router = useRouter();
1111

1212
// Set status code on SSR context if available
13-
// Cast to any because statusCode is not yet in the official types
1413
if (router.ssrContext) {
15-
(router.ssrContext as any).statusCode = code;
14+
router.ssrContext.statusCode = code;
1615
}
1716

1817
return <>{children}</>;

packages/magazin/routes/home.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,16 @@ function ProductCard({ slug, brand, category, name, price, image }: Product) {
66
return (
77
<Link
88
href={`/products/${slug}`}
9-
className="rounded-lg bg-stone-100/75 overflow-hidden hover:bg-stone-200/75 transition-colors"
9+
transition
10+
className="overflow-hidden group"
1011
>
1112
<div
12-
className="aspect-square p-12"
13+
className="aspect-square p-12 bg-stone-100/75 group-hover:bg-stone-200/75 transition-colors rounded-t-lg"
1314
style={{ viewTransitionName: `product-image-${slug}` }}
1415
>
1516
<img src={image} alt={name} className="object-cover w-full h-full" />
1617
</div>
17-
<div className="p-4">
18+
<div className="p-4 bg-stone-100/75 rounded-b-lg group-hover:bg-stone-200/75 transition-colors">
1819
<div className="text-sm text-neutral-400/75">
1920
{brand} · {category}
2021
</div>

packages/magazin/routes/products/[slug].tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,16 @@ export function ProductPage({ slug }: { slug: string }) {
2828
</Helmet>
2929
<Link
3030
href="/"
31+
transition
3132
className=" inline-flex items-center gap-2 hover:bg-neutral-100/75 rounded-md p-1.5 hover:text-neutral-900 mb-2"
3233
>
3334
<i className="iconoir-reply text-base" />
3435
</Link>
3536
<div className="grid grid-cols-3 gap-12">
36-
<div className="bg-stone-100/75 rounded-lg aspect-square col-span-2 p-12">
37+
<div
38+
className="bg-stone-100/75 rounded-lg aspect-square col-span-2 p-12"
39+
style={{ viewTransitionName: `product-image-${product.slug}` }}
40+
>
3741
<img
3842
src={product.image}
3943
alt={product.name}

packages/magazin/styles.css

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,13 @@
11
@import "tailwindcss";
2+
3+
/* View Transitions */
4+
@view-transition {
5+
navigation: auto;
6+
}
7+
8+
/* Default: simple 0.25s cross-fade */
9+
::view-transition-old(root),
10+
::view-transition-new(root) {
11+
animation-duration: 0.2s;
12+
animation-timing-function: ease;
13+
}

packages/wouter-preact/types/memory-location.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { BaseLocationHook, Path } from "./location-hook.js";
22

33
type Navigate<S = any> = (
44
to: Path,
5-
options?: { replace?: boolean; state?: S }
5+
options?: { replace?: boolean; state?: S; transition?: boolean }
66
) => void;
77

88
type HookReturnValue = { hook: BaseLocationHook; navigate: Navigate };

packages/wouter-preact/types/router.d.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,21 @@ export type Parser = (
1111
loose?: boolean
1212
) => { pattern: RegExp; keys: string[] };
1313

14+
// Standard navigation options supported by all built-in location hooks
15+
export type NavigateOptions<S = any> = {
16+
replace?: boolean;
17+
state?: S;
18+
/** Enable view transitions for this navigation (used with aroundNav) */
19+
transition?: boolean;
20+
};
21+
22+
// Function that wraps navigate calls, useful for view transitions
23+
export type AroundNavHandler = (
24+
navigate: (to: Path, options?: NavigateOptions) => void,
25+
to: Path,
26+
options?: NavigateOptions
27+
) => void;
28+
1429
// the object returned from `useRouter`
1530
export interface RouterObject {
1631
readonly hook: BaseLocationHook;
@@ -20,9 +35,19 @@ export interface RouterObject {
2035
readonly parser: Parser;
2136
readonly ssrPath?: Path;
2237
readonly ssrSearch?: SearchString;
38+
readonly ssrContext?: SsrContext;
2339
readonly hrefs: HrefsFormatter;
40+
readonly aroundNav: AroundNavHandler;
2441
}
2542

43+
// state captured during SSR render
44+
export type SsrContext = {
45+
// if a redirect was encountered, this will be populated with the path
46+
redirectTo?: Path;
47+
// HTTP status code to set for SSR response
48+
statusCode?: number;
49+
};
50+
2651
// basic options to construct a router
2752
export type RouterOptions = {
2853
hook?: BaseLocationHook;
@@ -31,5 +56,7 @@ export type RouterOptions = {
3156
parser?: Parser;
3257
ssrPath?: Path;
3358
ssrSearch?: SearchString;
59+
ssrContext?: SsrContext;
3460
hrefs?: HrefsFormatter;
61+
aroundNav?: AroundNavHandler;
3562
};

0 commit comments

Comments
 (0)