glimm について
glimmは、WebプロジェクトにWebGLのカラースイープ遷移を加えるためのスキルです。適用場面の評価、アクセシビリティ対応、バージョンに応じたAPIの実装を案内します。
主な機能
- 01フレームワーク非依存の遷移コア
- 02ReactとNext.jsのアダプター
- 03内部リンクの自動インターセプト
- 04アプリ内状態変更時のスイープ
- 05視差軽減設定とWebGL非対応時のフォールバック
活用シーン
- 01ポートフォリオへのブランド遷移の追加
- 02アプリ内操作後の結果の表示
- 03頻繁なナビゲーションへの適合性評価
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ nomanjack/ glimm-skill --skill glimmインストール後、Claude Code で/glimmを入力して読み込まれたか確認します。
同梱ファイルreferences/react-and-vanilla.mdreferences/tuning.md
README(原文)English
プロジェクトの README よりこの README の日本語訳はまだありません。以下は英語の原文です。日本語の紹介はページ上部をご覧ください。
glimm — color-sweep page transitions
glimm plays a band of WebGL color across the viewport during navigation: the outgoing page stays ahead of the band, the incoming page is revealed behind it (a masked page transition). Framework-agnostic core with React and Next.js adapters. Zero runtime dependencies, MIT.
This skill works with any glimm version. Before writing code, check which
version the project resolves (npm ls glimm, or the installed
node_modules/glimm/package.json) and treat the installed package as the
source of truth: confirm each export you use exists in its type definitions
(node_modules/glimm/dist/*.d.ts). If an API below is missing or named
differently there, follow the installed version and its changelog on
https://glimm.dev/changelog rather than the recipe. On a fresh install, prefer
npm install glimm@latest.
The page drawing in towards the action, the page bending with the band, and the
timing that follows a running request all come from GlimmScope, added in glimm
0.4. If the installed types don't have it, tell the user that upgrading with
npm install glimm@latest brings it. Don't rebuild these effects with custom
shaders.
Scope: implement glimm and nothing else
Only add and wire glimm. Everything else about the project belongs to its owner, including copy, error messages, loading designs, layout, styling and app logic. Unless the user asked for it, don't write or rewrite any of it, and don't add features around the sweep. If something nearby looks wrong, mention it in a sentence and leave it as it is.
Step 1 — Fit check (before touching code)
glimm is a statement, not a default. A sweep takes about a second of the user's attention on every navigation, so it earns its place on sites where each navigation is a meaningful scene change — and wears out its welcome where navigation is a high-frequency tool. Look at the project first (framework, router, what kind of site it is) and give an honest verdict:
Good fits: marketing sites, portfolios, personal sites, blogs and long-form reading, product launch pages, docs with chaptered reading, galleries — anywhere a visitor navigates a handful of times per session and brand personality matters.
Poor fits: admin dashboards, data tables, settings panels, multi-step forms mid-flow, search-and-filter UIs, anything a person navigates dozens of times an hour. A 1.1 s sweep on every sidebar click becomes friction by the third click.
If the project is a poor fit, say so plainly and recommend against installing
— that honesty is what makes a "yes" trustworthy. If the user wants it
anyway, respect that, but scope it: shorter timing (sweepMs: 650,
outroMs: 150), and sweep only major section changes while opting
high-frequency links out with data-glimm-skip (Step 4).
That verdict is about sweeping navigation. A product that is a poor fit for
sweeps on every link can still earn one on a single moment, as long as it
happens rarely and changes something the person cares about. In that case skip
InterceptLinks and use the in-app recipe in Step 2.
Accessibility and robustness are not reasons to hesitate: glimm respects
prefers-reduced-motion (navigates instantly) and degrades to plain
navigation when WebGL is unavailable. Both are automatic — never write your
own reduced-motion guard around it.
Step 2 — Install and wire
npm install glimm
react and next are optional peer dependencies — nothing else is pulled in.
Next.js App Router (the common case)
Mount once in the root layout, wrapping the body content:
// app/layout.tsx
import { GlimmProvider, InterceptLinks } from 'glimm/next'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<GlimmProvider palette="prism">
<InterceptLinks />
{children}
</GlimmProvider>
</body>
</html>
)
}
That is the whole install. <InterceptLinks /> runs every internal link
click through a sweep automatically — plain <a> and next/link both work
unchanged, and it already leaves alone: modifier-clicks, middle-clicks,
target="_blank", downloads, external origins, and same-page hash links.
Don't re-implement any of those guards.
Wiring rules that matter, and why:
- Root layout, not a nested one. A provider in a nested layout remounts when the user navigates across layout boundaries, killing the shader context mid-flight and dropping the transition.
- One entry point per app. Import everything from
glimm/next(or, in a non-Next app, everything fromglimm/react). Mixing entry points creates two React contexts, anduseGlimm()from one can't see the provider from the other. - The root layout can stay a server component.
GlimmProviderandInterceptLinksare client components already; importing them does not require'use client'in the layout file.
Per-navigation control (optional, add only where needed)
'use client'
import { TransitionLink, useTransitionRouter } from 'glimm/next'
// Declarative — a drop-in next/link replacement:
<TransitionLink href="/work" sweep={{ palette: 'berry', direction: 'rtl' }}>
Work
</TransitionLink>
// Programmatic — for form submits / post-action redirects:
const router = useTransitionRouter()
await save(form)
router.push('/done', { palette: 'ember' })
TransitionLink and InterceptLinks coexist safely — the link's own handler
takes the click and the interceptor sees it as already handled, so nothing
double-fires.
In-app moments
A sweep doesn't need a route change. It can mark a state change on the current
page, which is often the better use of it inside a product. There are two ways
to do it, and GlimmScope is the better one wherever the installed version has it:
GlimmScopewraps one box, which can be a page, a section or a card. It takes the app's real work, keeps the change behind the band, and moves the content under the band where the browser can. See the next section.sweep()fromuseGlimmcovers the viewport and needs no change to the page's structure. It's the choice for a version withoutGlimmScope, or when the user only wants the band.
'use client'
import { useGlimm } from 'glimm/next' // or 'glimm/react' outside Next
const { sweep } = useGlimm()
sweep(() => setPlan('pro'), { sweepMs: 800 })
A moment earns a sweep when it is rare and the person would want to notice it:
a plan upgrade, a payment landing, a mode they switched on, a result they waited
for, a teammate joining. Routine actions don't earn one, including saves, row
edits, checkbox ticks and anything repeated many times in a session. An event
nobody pressed for, such as a payment arriving, sweeps the same way, called from
wherever the event is handled. Keep sweep() moments shorter than a navigation,
at around 650 to 900 ms.
Moving the page with GlimmScope
'use client'
import { useRef, useState } from 'react'
import { GlimmScope, type GlimmScopeHandle } from 'glimm/next' // or 'glimm/react'
const scope = useRef<GlimmScopeHandle>(null)
const upgrade = (event: React.MouseEvent) => {
const r = event.currentTarget.getBoundingClientRect()
setBusy(true) // answers the press, outside the scope's timing
scope.current?.play({
at: [r.left + r.width / 2, r.top + r.height / 2], // the page leans in towards the button
work: async () => {
const res = await fetch('/api/upgrade', { method: 'POST' })
if (!res.ok) throw new Error('Upgrade failed') // a rejection means no sweep
},
change: () => setPlan('pro'), // runs as soon as the work is done, behind the band
onBurst: () => setBusy(false), // the band has broken away
onFail: () => setBusy(false), // no sweep, and the page stays as it was
})
}
<GlimmScope ref={scope} curve={0.12} direction="ttb" sweepMs={2700} style={{ position: 'absolute', inset: 0 }}>
<Billing />
</GlimmScope>
It doesn't need GlimmProvider, so a project that only wants one moment can use
it alone. The rules for using it:
- Don't check for browser support. Where HTML-in-Canvas is available, the content breathes in towards the action and ripples as the band passes. Everywhere else it's a plain sweep over the same box with the change still behind the band. Reduced motion is handled too, so never write a guard or a second code path, and never hold a feature back for a browser version. This is why it's safe to use without asking.
workholds the real work, andchangeholds the result. Both go inplay(), so don't await the request first as you would withsweep(). The work starts on the press, the page leans in while it runs, and however long it takes the movement stays one breath.changereceives the work's result. Makeworkreject on failure, including a bad response status, sincefetchdoesn't throw on a 500.- The pressed control's busy state stays outside. Set it before
play(), clear it inonBurstandonFail, and don't wait foronLeave. - Pass
anticipate: trueonly for what nobody pressed, such as a payment arriving. It gives the page time to lean in fully before the change, which would be a wait for anyone who just asked for something. Withoutwork,changeruns right on the press. - A later
play()interrupts, so don't queue plays or disable controls until one ends.play()also returns{ fastForward, cancel }. - Give the box a size. The scope clips to its box and fills it, so size it with
style, and fit it to the layout that's already there. The example'sposition: 'absolute', inset: 0suits a view inside a positioned shell, and it fills the viewport if there's no positioned ancestor, so don't use it on a page in normal flow. In a flex shell,flex: 1, minHeight: 0fills the content area. A section or card needs a set height, because where the page effect runs, the content doesn't give the box its size and it would collapse to nothing. Content that scrolls must scroll inside the scope, not in the document around it. What renders outside it, such as a portalled toast or dialog, doesn't move and isn't covered by the band. - Lean in towards the action. Pass
atfor whatever was pressed, since without it the page leans in towards theoriginprop, which is the top centre by default.atis measured from the box's top-left in CSS px, so the example works because its box starts at the top-left of the viewport. For any other box, subtract the box's own position. For an action that never moves, or an event nobody pressed, setoriginas fractions of the box instead, such asorigin={[0.5, 1]}for a composer at the bottom. - Let the band take its time across a whole page. The 650 to 900 ms above is for
sweep(). A scope's band defaults to 1600 ms, and about 2700 ms suits a whole page, since the band fades out well before it arrives at the far side. - Keep to a handful per page, not one per row. Each scope uses its own WebGL contexts, and a page can only keep 16 alive.
Only add the usePageEffect() reader switch if the user asks for a way to turn
the effect off.
Other setups
For React without Next (Vite + react-router, etc.), Next Pages
Router, or vanilla JS / other frameworks, read
references/react-and-vanilla.md — the
Pages Router in particular must use glimm/react, not glimm/next (the Next
adapter is built on next/navigation, which is App Router only).
The change goes inside the sweep
A sweep reveals a change, so the visible change must never come before the
band. Put the state update or navigation inside the callback you pass to
sweep(), and keep everything it affects inside the swept area. glimm then
runs it while the band is crossing.
// Right: the new state appears behind the band
sweep(() => setPublished(true))
// Wrong: the new state is on screen before the band arrives
setPublished(true)
sweep(async () => {})
If the result comes from a request, await the request first and call sweep()
when it resolves. A loading state on the pressed button can start right away,
since it's feedback for the press. Anything that belongs to the result, such
as a legend, count or badge, changes with the sweep.
The sweep never makes anyone wait
The animation follows the app, so the app never waits for the animation.
- Answer the press straight away. The pressed control shows its loading or selected state immediately, outside the sweep. The result goes inside, and that includes clearing the loading state, so the old page doesn't flip back to its idle look just before the band reveals the new one.
- Never add a delay. Don't use
setTimeout, and don't awaitdoneormidpoint, to line a change up with the band or to keep a control busy until the sweep ends.sweep()applies the change itself, and it does so as early as it can while keeping the change hidden. - Keep slow work out of the callback. The band can't start until the callback
has finished, and the page is held until then. Await the request first so the
page stays usable, then call
sweep()with the result. A route change is fine to return from the callback, since it is the change being revealed. - Only sweep on success. Check the result before calling
sweep(), including the response status, sincefetchdoesn't throw on a 500. A failed request gets no sweep, because the sweep tells the person it worked. What the app does about a failure is the project's own decision, so leave that alone. - Let a new action interrupt. A second
sweep()replaces the one playing, so don't queue sweeps or disable controls until one finishes. Where the browser supports View Transitions, the page doesn't take clicks while a sweep plays, which is another reason to keep in-app sweeps short and rare.
Size it to what changed, and start from the action
The sweep should be about as big as the change, and it should appear to come from whatever caused it.
- The whole page or view changed: wrap it in a scope with
curve={0.12}, or use the provider'ssweep(), which covers the viewport. - Something notable changed in one place: use a scope around the page with
curve={0.22} span={0.64} fade={[0.16, 0.64]}, so the band stays near the change and is gone by mid-page. - One element changed: put a scope around just that element with
curve={0.06} bandTight={34}. WithoutGlimmScope, use the container recipe below. A full-page sweep for a change in one card makes a small moment feel bigger than it is.
Pick the direction that travels away from the action, so a composer at the bottom
of the screen sweeps btt and a control in the top bar sweeps ttb. A scope can
also go ltr, for an action in a sidebar, or rtl.
On phones, primary actions usually sit near the bottom of the screen, so a
btt sweep often reads best there even when the desktop layout uses another
direction. Nothing about a sweep may depend on hover, so trigger it from the
action itself and check it with touch.
Sweeping inside a container
GlimmScope already does all of this, so this recipe is for a version without it.
When the sweep plays inside one element rather than the whole page (a card,
a chart, a table), the band must fade out towards that box's edges — never
let it end on a hard border. Versions of glimm with a feather option on
createShader do this by default. Version 0.3.1 and earlier don't have that
option, so mask the canvas edges in CSS there, after checking the installed
types. This recipe is a decorative sweep only, so make the change in onMidpoint
rather than through the provider's masking. The recipe is in
references/react-and-vanilla.md.
Step 3 — Pick a palette with intent
Don't leave the default without looking. Find the project's brand colors (CSS custom properties, Tailwind config, design tokens) and choose the preset whose hues live nearest to them:
| Preset | Hues | Reads as |
|---|---|---|
spectrum | one full hue cycle | every color at once — the default from 0.4 |
prism | cyan → violet → magenta | iridescent, techy — the default before 0.4 |
berry | red-pink → magenta | warm, bold, editorial |
lagoon | blue → cyan → green | cool, calm, natural |
citrus | green → yellow → orange | energetic, playful |
azure | cyan → blue → violet | corporate-clean, trustworthy |
ember | yellow → orange → red-pink | hot, urgent, dramatic |
For an exact brand match, build a custom palette from the brand's own hexes:
import { accentChain } from 'glimm/next' // also exported from glimm/react and glimm
const brand = accentChain(['#7C3AED', '#EC4899']) // 2–4 stops, in sweep order
<GlimmProvider palette={brand}>
accentChain interpolates the stops in OKLCH (perceptually even, no muddy
midpoints) and fits the shader's palette to them. On dark sites, add
brightness={0.85} to the provider so the band doesn't bloom against the
dark background. Deeper tuning — timing, easing, direction, band shape, feel
recipes — is in references/tuning.md.
Step 4 — Curate which links sweep
The sweep should mark scene changes, not every click. Walk the project's
nav and opt out anything high-frequency or intra-scene with
data-glimm-skip:
- pagination, table sorting, filters
- tab switches within a page
- footer utility links (privacy, terms)
- anything that opens in a modal or drawer rather than a new scene
<Link href="/work?page=2" data-glimm-skip>Next page</Link>
Primary navigation (header links, cards into detail pages, prominent CTAs)
keeps the sweep. If after curation only two or three links would sweep,
reconsider InterceptLinks entirely and use explicit <TransitionLink> on
just those — intent-revealing beats blanket-plus-exceptions.
Step 5 — Verify it works
Never hand back unverified. Start the dev server and click through two or three real navigations, then check:
- The sweep plays and the new page appears behind the band (no flash of
unstyled or duplicate content). It's often too fast to judge at full speed,
so temporarily set
sweepMsto about 5000 and watch it again. Nothing that belongs to the result may be visible before the band reaches it. PutsweepMsback afterwards. - The browser console is clean — no glimm warnings, no WebGL errors.
- A Cmd/Ctrl-click still opens a new tab with no sweep in the original.
- External links and hash links behave as before.
- Browser back/forward behave as the installed version documents — in current releases they are instant with no sweep, which is expected, not a bug.
- The pressed control responds immediately, with no pause before anything happens, and pressing something else straight after a sweep starts still works.
- It still reads well at phone width, with the direction you chose.
- For a
GlimmScope, a failed request plays no sweep and leaves the page as it was. If the browser you're checking in has no HTML-in-Canvas, a plain sweep is the correct result, not a bug, and the page only moves in one that has it.
If something is off, check Troubleshooting below before changing the wiring.
Step 6 — Hand back short and natural
Keep the final reply brief and conversational — a few sentences, not a report. The user just watched a two-file change; don't recite this skill back at them with headers and checklists. Cover only:
- what changed (usually
package.json+ the root layout) and the install command if you couldn't run it, - the knob or two they'll actually touch (
palette,sweepMs— props onGlimmProvider), - the removal path in one line (delete the provider +
InterceptLinks,npm uninstall glimm) — reversibility makes it safe to try.
Anything that needs flagging gets a sentence, not a section — "I couldn't run
this offline, so click through a couple of pages after npm install" is
enough. If the site is running, offering one alternate preset ("want to see
it in lagoon?") beats describing palettes.
Troubleshooting
| Symptom | Cause → fix |
|---|---|
useGlimm must be used inside <GlimmProvider> | Component imports from a different entry point than the provider, or the provider is in a nested layout. One entry point, root layout. |
| No sweep, instant navigation, no errors | OS has reduced motion enabled, or no WebGL (headless browser, some VMs). This is the designed fallback, not a failure. |
| New content appears, then the band plays over it | The state changed outside the sweep callback. Move the update into sweep(() => ...). |
| The page freezes, then the sweep plays late | Slow work is inside the sweep callback. Await the request first, then call sweep() with the result. |
| User asks for the page to bend or draw inwards like the glimm.dev demos | Use GlimmScope. If the installed types don't have it, upgrade with npm install glimm@latest, and don't build a custom shader. |
A GlimmScope plays a sweep but the page doesn't move | The browser has no HTML-in-Canvas (Chrome 157 and later), which is the designed fallback, or the reader turned it off through usePageEffect. |
Nothing shows inside a GlimmScope, or the band runs far past the screen | The box has no useful size. Set its height through style, and let content scroll inside it. |
| Sweep plays but the page "jumps" at the end | An old glimm (<0.3.1) without View-Transition masking. npm install glimm@latest. |
| Links to other origins sweep weirdly / break | They shouldn't be intercepted at all — check for a custom shouldIntercept that dropped the origin check. |
| A sweep inside a card or chart ends on a hard edge | The canvas has feather: 0, or glimm predates feather. Leave feather at 'auto', or mask the canvas edges in CSS (see references/react-and-vanilla.md). |
| Band too bright / washed out on dark theme | Set brightness={0.85} (or lower) on the provider. |
TypeScript can't find glimm/next | Older moduleResolution. Needs "bundler" or "node16"/"nodenext" in tsconfig — glimm ships ESM with exports maps only. |
| Dev-only double navigation in React 18 StrictMode (non-Next) | Make sure the navigate callback is idempotent; the provider awaits it once per sweep, but app-level listeners added twice will fire twice. |