mcpField
G
glimm

glimm

github.com/nomanjackデザイン・UI012 回インストール1 日前に更新
リポジトリ

glimm について

glimmは、WebプロジェクトにWebGLのカラースイープ遷移を加えるためのスキルです。適用場面の評価、アクセシビリティ対応、バージョンに応じたAPIの実装を案内します。

主な機能

  1. 01フレームワーク非依存の遷移コア
  2. 02ReactとNext.jsのアダプター
  3. 03内部リンクの自動インターセプト
  4. 04アプリ内状態変更時のスイープ
  5. 05視差軽減設定とWebGL非対応時のフォールバック

活用シーン

  1. 01ポートフォリオへのブランド遷移の追加
  2. 02アプリ内操作後の結果の表示
  3. 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 from glimm/react). Mixing entry points creates two React contexts, and useGlimm() from one can't see the provider from the other.
  • The root layout can stay a server component. GlimmProvider and InterceptLinks are 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:

  • GlimmScope wraps 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() from useGlimm covers the viewport and needs no change to the page's structure. It's the choice for a version without GlimmScope, 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.
  • work holds the real work, and change holds the result. Both go in play(), so don't await the request first as you would with sweep(). The work starts on the press, the page leans in while it runs, and however long it takes the movement stays one breath. change receives the work's result. Make work reject on failure, including a bad response status, since fetch doesn't throw on a 500.
  • The pressed control's busy state stays outside. Set it before play(), clear it in onBurst and onFail, and don't wait for onLeave.
  • Pass anticipate: true only 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. Without work, change runs 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's position: 'absolute', inset: 0 suits 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: 0 fills 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 at for whatever was pressed, since without it the page leans in towards the origin prop, which is the top centre by default. at is 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, set origin as fractions of the box instead, such as origin={[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 await done or midpoint, 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, since fetch doesn'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's sweep(), 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}. Without GlimmScope, 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:

PresetHuesReads as
spectrumone full hue cycleevery color at once — the default from 0.4
prismcyan → violet → magentairidescent, techy — the default before 0.4
berryred-pink → magentawarm, bold, editorial
lagoonblue → cyan → greencool, calm, natural
citrusgreen → yellow → orangeenergetic, playful
azurecyan → blue → violetcorporate-clean, trustworthy
emberyellow → orange → red-pinkhot, 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:

  1. 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 sweepMs to about 5000 and watch it again. Nothing that belongs to the result may be visible before the band reaches it. Put sweepMs back afterwards.
  2. The browser console is clean — no glimm warnings, no WebGL errors.
  3. A Cmd/Ctrl-click still opens a new tab with no sweep in the original.
  4. External links and hash links behave as before.
  5. Browser back/forward behave as the installed version documents — in current releases they are instant with no sweep, which is expected, not a bug.
  6. The pressed control responds immediately, with no pause before anything happens, and pressing something else straight after a sweep starts still works.
  7. It still reads well at phone width, with the direction you chose.
  8. 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 on GlimmProvider),
  • 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

SymptomCause → 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 errorsOS 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 itThe state changed outside the sweep callback. Move the update into sweep(() => ...).
The page freezes, then the sweep plays lateSlow 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 demosUse 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 moveThe 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 screenThe box has no useful size. Set its height through style, and let content scroll inside it.
Sweep plays but the page "jumps" at the endAn old glimm (<0.3.1) without View-Transition masking. npm install glimm@latest.
Links to other origins sweep weirdly / breakThey 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 edgeThe 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 themeSet brightness={0.85} (or lower) on the provider.
TypeScript can't find glimm/nextOlder 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.

コメント