diff --git a/content/docs/components/folio.mdx b/content/docs/components/folio.mdx
new file mode 100644
index 0000000..a057277
--- /dev/null
+++ b/content/docs/components/folio.mdx
@@ -0,0 +1,110 @@
+---
+title: Folio
+description: The page tips one way as you scroll down, the other as you scroll up, then springs flat when you stop.
+---
+
+import { FolioDemo } from "@/registry/folio/folio-demo"
+
+A single sheet. Scroll down and it tips a few degrees one way; scroll up and it tips the other. The lean stays around 16° at a flick so a long page does not swing through the camera. Blur rides the traveling edge. Stop and it springs to the original plane. At the bottom the down-lean lets go; at the top the up-lean does.
+
+
+
+## Installation
+
+
+
+Or install straight from the public GitHub source registry:
+
+
+
+## Usage
+
+Use it as the scroll viewport. Children are the page. Wheel, touch, or trackpad tilts the sheet with the scroll — down one way, up the other. Idle returns it. Honors `prefers-reduced-motion` (no tilt, no blur).
+
+
+
+```tsx
+"use client"
+
+import { Folio } from "@/components/ui/folio"
+
+export function Page() {
+ return (
+
+
+
+ The page leans back
+
+
+ Put the site in children. Folio is the scroller.
+
+
+
+ )
+}
+```
+
+
+```svelte
+
+
+
+
+
+ The page leans back
+
+
+ Put the site in children. Folio is the scroller.
+
+
+
+```
+
+
+
+On a full document (window scroll), pass `windowScroll` and mark the page root with `data-folio-page`:
+
+
+
+```tsx
+"use client"
+
+import { Folio } from "@/components/ui/folio"
+
+export function App() {
+ return (
+ <>
+
{/* page content */}
+
+ >
+ )
+}
+```
+
+
+```svelte
+
+
+
+
+```
+
+
+
+## Props
+
+| Prop | Type | Default |
+| --- | --- | --- |
+| `children` | `ReactNode` / snippet | — |
+| `blur` | `number` | `4` — peak blur on the traveling edge |
+| `perspective` | `number` | `1000` — floor `1000` |
+| `returnMs` | `number` | `520` — time for the lean to come back after the bottom |
+| `windowScroll` | `boolean` | `false` |
+| `contentSelector` | `string` | `[data-folio-page]` |
+| `label` | `string` | `Tilting page` |
+| `className` | `string` | React only |
+| `class` | `string` | Svelte only |
diff --git a/content/docs/components/meta.json b/content/docs/components/meta.json
index ad28742..0197ddf 100644
--- a/content/docs/components/meta.json
+++ b/content/docs/components/meta.json
@@ -17,6 +17,8 @@
"ascii-logo",
"---Pages---",
"dithered-404",
+ "---Sections---",
+ "folio",
"---Components---",
"gooey-color-picker"
]
diff --git a/content/docs/index.mdx b/content/docs/index.mdx
index b481c89..3df1510 100644
--- a/content/docs/index.mdx
+++ b/content/docs/index.mdx
@@ -25,3 +25,4 @@ Most component kits hand you every option. 23rd picks a direction: spacing, radi
- [Phosphor Score](/docs/components/phosphor-score) — vertical CRT sheet music that falls and flares
- [Radiant Lines](/docs/components/radiant-lines) — hyperspace starfield background
- [Dithered 404](/docs/components/dithered-404) — Bayer-pixel 404 that burns under a fireball cursor
+- [Folio](/docs/components/folio) — the page leans back on scroll, then springs flat
diff --git a/public/r/folio-svelte.json b/public/r/folio-svelte.json
new file mode 100644
index 0000000..7be38c8
--- /dev/null
+++ b/public/r/folio-svelte.json
@@ -0,0 +1,15 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "folio-svelte",
+ "title": "Folio",
+ "description": "The whole page leans back in perspective while you scroll, blurs, then springs flat when you stop.",
+ "files": [
+ {
+ "path": "registry/folio/folio.svelte",
+ "content": "\n\n\n\n{#if !windowScroll}\n
\n
\n {@render children?.()}\n
\n
\n{/if}\n",
+ "type": "registry:file",
+ "target": "src/lib/components/ui/folio.svelte"
+ }
+ ],
+ "type": "registry:ui"
+}
\ No newline at end of file
diff --git a/public/r/folio.json b/public/r/folio.json
new file mode 100644
index 0000000..3cde265
--- /dev/null
+++ b/public/r/folio.json
@@ -0,0 +1,15 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "folio",
+ "title": "Folio",
+ "description": "The whole page leans back in perspective while you scroll, blurs, then springs flat when you stop.",
+ "files": [
+ {
+ "path": "registry/folio/folio.tsx",
+ "content": "\"use client\"\n\nimport { useEffect, useRef, type ReactNode } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport const FOLIO_PLAY = \"folio:play\"\nexport const FOLIO_IDLE_MS = 200\n\nexport type FolioPlayDetail = {\n /** Only instances whose `demoId` matches will play. */\n target?: string\n /** Nested scroller to drive. Omit for `window`. */\n scrollRoot?: HTMLElement | null\n /** How long to hold the lean before springing back, in ms. Default `420`. */\n holdMs?: number\n}\n\nexport type FolioRuntimeOptions = {\n /** Peak blur in px at full tilt. Default `4`. */\n blur?: number\n /** CSS perspective distance in px. Default `1000`. Floor `1000`. */\n perspective?: number\n /**\n * How long the lean takes to come back after the bottom, in ms.\n * Default `520`.\n */\n returnMs?: number\n}\n\nexport type FolioInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\nconst TILT_PEAK = 16\nconst DEFAULT_BLUR = 4\nconst DEFAULT_PERSPECTIVE = 1000\nconst MIN_PERSPECTIVE = 1000\nconst DEFAULT_RETURN_MS = 520\nconst VEL_REF = 2.4\nconst WHEEL_REF = 110\nconst WHEEL_SCROLL_LOCK_MS = 64\nconst END_SLACK_PX = 8\nconst SPRING_IN = { stiffness: 72, damping: 22 }\nconst SPRING_OUT = { stiffness: 42, damping: 18 }\n\ntype Spring = {\n set: (value: number) => void\n get: () => number\n destroy: () => void\n}\n\nfunction createSpring(onChange: (value: number) => void): Spring {\n let current = 0\n let target = 0\n let velocity = 0\n let raf = 0\n let lastTime = 0\n let stiffness = SPRING_IN.stiffness\n let damping = SPRING_IN.damping\n\n function integrate(dt: number) {\n const accel = -stiffness * (current - target) - damping * velocity\n velocity += accel * dt\n current += velocity * dt\n }\n\n function tick(now: number) {\n if (!lastTime) lastTime = now\n let remaining = Math.min((now - lastTime) / 1000, 0.048)\n lastTime = now\n const step = 1 / 60\n while (remaining > 0) {\n integrate(Math.min(step, remaining))\n remaining -= step\n }\n const settled =\n Math.abs(current - target) < 0.03 && Math.abs(velocity) < 0.03\n if (settled) {\n current = target\n velocity = 0\n raf = 0\n lastTime = 0\n onChange(current)\n return\n }\n onChange(current)\n raf = requestAnimationFrame(tick)\n }\n\n function start() {\n if (!raf) {\n lastTime = 0\n raf = requestAnimationFrame(tick)\n }\n }\n\n return {\n set(value) {\n target = value\n if (value === 0) {\n stiffness = SPRING_OUT.stiffness\n damping = SPRING_OUT.damping\n } else {\n stiffness = SPRING_IN.stiffness\n damping = SPRING_IN.damping\n }\n if (Math.abs(current - target) < 0.03 && Math.abs(velocity) < 0.03) {\n current = target\n velocity = 0\n onChange(current)\n return\n }\n start()\n },\n get() {\n return current\n },\n destroy() {\n if (raf) cancelAnimationFrame(raf)\n raf = 0\n },\n }\n}\n\nfunction isWindow(scroller: HTMLElement | Window): scroller is Window {\n return scroller === window\n}\n\nfunction readScrollTop(scroller: HTMLElement | Window) {\n return isWindow(scroller)\n ? window.scrollY || document.documentElement.scrollTop\n : scroller.scrollTop\n}\n\nfunction maxScroll(scroller: HTMLElement | Window) {\n if (isWindow(scroller)) {\n return Math.max(\n 0,\n document.documentElement.scrollHeight - window.innerHeight\n )\n }\n return Math.max(0, scroller.scrollHeight - scroller.clientHeight)\n}\n\nfunction clamp01(n: number) {\n return Math.max(0, Math.min(1, n))\n}\n\nfunction clampSigned(n: number) {\n return Math.max(-1, Math.min(1, n))\n}\n\nfunction remainingScroll(scroller: HTMLElement | Window) {\n return Math.max(0, maxScroll(scroller) - readScrollTop(scroller))\n}\n\n/** `1` in the body of the page, `0` at the bottom so the down-lean dies. */\nfunction endFade(scroller: HTMLElement | Window) {\n const left = remainingScroll(scroller)\n if (left <= END_SLACK_PX) return 0\n const zone = Math.max(96, viewHeightOf(scroller) * 0.4)\n return clamp01((left - END_SLACK_PX) / zone)\n}\n\n/** `1` in the body of the page, `0` at the top so the up-lean dies. */\nfunction startFade(scroller: HTMLElement | Window) {\n const top = readScrollTop(scroller)\n if (top <= END_SLACK_PX) return 0\n const zone = Math.max(96, viewHeightOf(scroller) * 0.4)\n return clamp01((top - END_SLACK_PX) / zone)\n}\n\n/** Map scroll delta to a signed -1…1 lean. Positive = scrolling down. */\nexport function leanFromDelta(deltaPx: number, ref = WHEEL_REF) {\n return clampSigned(deltaPx / ref)\n}\n\n/** Slow travel sits near 5°, a flick reaches 16°. */\nfunction tiltAngle(impulse: number) {\n const mag = Math.min(1, Math.abs(impulse))\n const deg = mag * mag * (TILT_PEAK - 5) + mag * 5\n return deg * Math.sign(impulse || 1)\n}\n\nfunction hingeY(\n scroller: HTMLElement | Window,\n plane: HTMLElement,\n tilt: number\n) {\n const vh = viewHeightOf(scroller)\n const localViewTop = readScrollTop(scroller) - plane.offsetTop\n if (Math.abs(tilt) < 0.35) return localViewTop + vh * 0.5\n return tilt > 0 ? localViewTop + vh : localViewTop\n}\n\nexport function applyFolioFrame(\n plane: HTMLElement,\n tilt: number,\n options: FolioRuntimeOptions = {},\n reducedMotion = false,\n originY?: number\n) {\n const blur = Math.max(0, options.blur ?? DEFAULT_BLUR)\n const perspective = Math.max(\n MIN_PERSPECTIVE,\n options.perspective ?? DEFAULT_PERSPECTIVE\n )\n const angle = reducedMotion ? 0 : tilt\n const amount = Math.min(1, Math.abs(angle) / TILT_PEAK)\n const blurPx = reducedMotion ? 0 : amount * blur\n\n plane.style.transformOrigin =\n originY !== undefined\n ? `50% ${originY.toFixed(1)}px`\n : Math.abs(angle) < 0.35\n ? \"50% 50%\"\n : angle > 0\n ? \"50% 100%\"\n : \"50% 0%\"\n plane.style.backfaceVisibility = \"hidden\"\n plane.style.transform = `perspective(${perspective}px) rotateX(${angle}deg)`\n plane.style.filter = \"none\"\n\n const veil = ensureBlurVeil(plane)\n if (blurPx < 0.08) {\n veil.style.cssText = \"display:none\"\n } else {\n const ramp =\n angle >= 0\n ? \"linear-gradient(to top, transparent 0%, transparent 32%, black 100%)\"\n : \"linear-gradient(to bottom, transparent 0%, transparent 32%, black 100%)\"\n veil.style.cssText = [\n \"pointer-events:none\",\n \"position:absolute\",\n \"inset:0\",\n \"z-index:2\",\n `backdrop-filter:blur(${blurPx.toFixed(2)}px)`,\n `-webkit-backdrop-filter:blur(${blurPx.toFixed(2)}px)`,\n `mask-image:${ramp}`,\n `-webkit-mask-image:${ramp}`,\n ].join(\";\")\n }\n plane.style.willChange = Math.abs(angle) > 0.08 ? \"transform\" : \"auto\"\n}\n\nfunction animateScroll(\n scroller: HTMLElement | Window,\n to: number,\n durationMs: number,\n signal: AbortSignal\n) {\n const from = readScrollTop(scroller)\n if (durationMs <= 0) {\n if (isWindow(scroller)) window.scrollTo({ top: to })\n else scroller.scrollTop = to\n return Promise.resolve()\n }\n\n const start = performance.now()\n return new Promise((resolve) => {\n const step = (now: number) => {\n if (signal.aborted) {\n resolve()\n return\n }\n const t = Math.min(1, (now - start) / durationMs)\n const e = t * t * t * (t * (t * 6 - 15) + 10)\n const y = from + (to - from) * e\n if (isWindow(scroller)) window.scrollTo({ top: y })\n else scroller.scrollTop = y\n if (t < 1) requestAnimationFrame(step)\n else resolve()\n }\n requestAnimationFrame(step)\n })\n}\n\n/** Lean the page while scrolling down, then spring flat. */\nexport async function playFolioDemo(detail: FolioPlayDetail = {}) {\n if (typeof window === \"undefined\") return\n window.dispatchEvent(\n new CustomEvent(FOLIO_PLAY, { detail })\n )\n const hold = detail.holdMs ?? 720\n await new Promise((r) => window.setTimeout(r, hold + 1800))\n}\n\nfunction viewHeightOf(scroller: HTMLElement | Window) {\n return isWindow(scroller) ? window.innerHeight : scroller.clientHeight\n}\n\nfunction ensureBlurVeil(plane: HTMLElement) {\n let veil = plane.querySelector(\"[data-slot='folio-blur-veil']\")\n if (!veil) {\n if (getComputedStyle(plane).position === \"static\") {\n plane.style.position = \"relative\"\n }\n veil = document.createElement(\"div\")\n veil.dataset.slot = \"folio-blur-veil\"\n veil.setAttribute(\"aria-hidden\", \"true\")\n plane.appendChild(veil)\n }\n return veil\n}\n\nexport function createFolio(options: {\n plane: HTMLElement\n scroller: HTMLElement | Window\n demoId?: string\n blur?: number\n perspective?: number\n returnMs?: number\n}): FolioInstance {\n const runtime: FolioRuntimeOptions = {\n blur: options.blur,\n perspective: options.perspective,\n returnMs: options.returnMs,\n }\n\n let reduced = false\n let playing = false\n let lastTop = readScrollTop(options.scroller)\n let lastTime = performance.now()\n let lastWheelAt = 0\n let impulse = 0\n let gate = 1\n let gateFrom = 1\n let gateTarget = 1\n let gateT0 = 0\n let gateRaf = 0\n let idleTimer: ReturnType | null = null\n const playAbort = { current: new AbortController() }\n\n const paint = (tilt: number) => {\n applyFolioFrame(\n options.plane,\n tilt,\n runtime,\n reduced,\n hingeY(options.scroller, options.plane, tilt)\n )\n }\n\n const spring = createSpring(paint)\n\n const media = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const applyReduce = () => {\n reduced = media.matches && !playing\n if (reduced) {\n impulse = 0\n gate = 1\n spring.set(0)\n paint(0)\n }\n }\n applyReduce()\n media.addEventListener(\"change\", applyReduce)\n\n function returnMs() {\n return Math.max(0, runtime.returnMs ?? DEFAULT_RETURN_MS)\n }\n\n function sampleGate(now = performance.now()) {\n const ms = returnMs()\n if (ms <= 0) {\n gate = gateTarget\n return gate\n }\n const t = Math.min(1, (now - gateT0) / ms)\n const e = t * t * (3 - 2 * t)\n gate = gateFrom + (gateTarget - gateFrom) * e\n return gate\n }\n\n function goGate(to: number) {\n if (to === 0) {\n gate = 0\n gateFrom = 0\n gateTarget = 0\n if (gateRaf) cancelAnimationFrame(gateRaf)\n gateRaf = 0\n return\n }\n if (gateTarget !== 1) {\n gateFrom = sampleGate()\n gateTarget = 1\n gateT0 = performance.now()\n }\n if (gate >= 0.999) {\n gate = 1\n if (gateRaf) cancelAnimationFrame(gateRaf)\n gateRaf = 0\n return\n }\n if (gateRaf) return\n const tick = (now: number) => {\n sampleGate(now)\n applyLean()\n if (gateTarget === 1 && gate < 0.999) {\n gateRaf = requestAnimationFrame(tick)\n } else {\n if (gateTarget === 1) gate = 1\n gateRaf = 0\n }\n }\n gateRaf = requestAnimationFrame(tick)\n }\n\n function applyLean(leavingEdge = false) {\n if (reduced) {\n spring.set(0)\n return\n }\n if (playing) {\n spring.set(tiltAngle(impulse || 1))\n return\n }\n const down = impulse >= 0\n const fade = leavingEdge\n ? 1\n : down\n ? endFade(options.scroller)\n : startFade(options.scroller)\n if (fade <= 0.02) {\n goGate(0)\n impulse = 0\n spring.set(0)\n return\n }\n goGate(1)\n const g = sampleGate()\n spring.set(tiltAngle(impulse * fade * g))\n }\n\n function lean(amount: number, leavingEdge = false) {\n if (reduced) return\n const next = clampSigned(amount)\n const nextSign = Math.sign(next)\n const prevSign = Math.sign(impulse)\n if (nextSign !== 0 && prevSign !== 0 && nextSign !== prevSign) {\n impulse = next\n } else {\n const dir = nextSign || prevSign || 1\n const mag = Math.max(\n Math.abs(next),\n Math.abs(impulse) * 0.62 + Math.abs(next) * 0.38\n )\n impulse = mag * dir\n }\n applyLean(leavingEdge)\n if (idleTimer) clearTimeout(idleTimer)\n idleTimer = setTimeout(() => {\n impulse = 0\n if (!playing) spring.set(0)\n }, FOLIO_IDLE_MS)\n }\n\n const onWheel = (event: Event) => {\n if (playing) return\n const dy = (event as WheelEvent).deltaY\n if (!dy) return\n lastWheelAt = performance.now()\n const atEnd = endFade(options.scroller) <= 0.02\n const atStart = startFade(options.scroller) <= 0.02\n const leavingEnd = atEnd && dy < 0\n const leavingStart = atStart && dy > 0\n if ((atEnd && !leavingEnd) || (atStart && !leavingStart)) {\n goGate(0)\n impulse = 0\n spring.set(0)\n return\n }\n lean(leanFromDelta(dy), leavingEnd || leavingStart)\n }\n\n const onScroll = () => {\n const now = performance.now()\n const top = readScrollTop(options.scroller)\n const dt = Math.max(8, now - lastTime)\n const vel = (top - lastTop) / dt\n lastTop = top\n lastTime = now\n if (playing) {\n applyLean()\n return\n }\n const atEnd = endFade(options.scroller) <= 0.02\n const atStart = startFade(options.scroller) <= 0.02\n if (atEnd && vel >= 0) {\n goGate(0)\n impulse = 0\n spring.set(0)\n return\n }\n if (atStart && vel <= 0) {\n goGate(0)\n impulse = 0\n spring.set(0)\n return\n }\n if (now - lastWheelAt < WHEEL_SCROLL_LOCK_MS) {\n applyLean(atEnd || atStart)\n return\n }\n lean(clampSigned(vel / VEL_REF), (atEnd && vel < 0) || (atStart && vel > 0))\n }\n\n const wheelTarget: EventTarget = isWindow(options.scroller)\n ? window\n : options.scroller\n wheelTarget.addEventListener(\"wheel\", onWheel, { passive: true })\n options.scroller.addEventListener(\"scroll\", onScroll, { passive: true })\n const onResize = () => paint(spring.get())\n window.addEventListener(\"resize\", onResize)\n\n const onPlay = (event: Event) => {\n const detail = (event as CustomEvent).detail ?? {}\n if (options.demoId) {\n if (!detail.target || detail.target !== options.demoId) return\n } else if (detail.target) {\n return\n }\n\n playAbort.current.abort()\n playAbort.current = new AbortController()\n const signal = playAbort.current.signal\n const hold = detail.holdMs ?? 720\n const scroller = detail.scrollRoot ?? options.scroller\n playing = true\n if (idleTimer) clearTimeout(idleTimer)\n applyReduce()\n\n void (async () => {\n const max = maxScroll(scroller)\n const zone = Math.max(96, viewHeightOf(scroller) * 0.4)\n const limit = Math.max(0, max - zone - END_SLACK_PX)\n let start = readScrollTop(scroller)\n if (start >= limit - 24) {\n impulse = 0\n gate = 1\n gateTarget = 1\n spring.set(0)\n if (isWindow(scroller)) window.scrollTo({ top: 0 })\n else scroller.scrollTop = 0\n lastTop = 0\n start = 0\n }\n const dest = Math.min(\n limit,\n start + Math.max(560, viewHeightOf(scroller) * 1.35)\n )\n impulse = 1\n gate = 1\n gateTarget = 1\n spring.set(TILT_PEAK)\n await animateScroll(scroller, dest, Math.max(1400, hold + 700), signal)\n if (signal.aborted) {\n playing = false\n applyReduce()\n return\n }\n playing = false\n applyReduce()\n impulse = 0\n spring.set(0)\n })()\n }\n\n window.addEventListener(FOLIO_PLAY, onPlay)\n paint(0)\n\n return {\n setOptions(next) {\n if (next.blur !== undefined) runtime.blur = next.blur\n if (next.perspective !== undefined) runtime.perspective = next.perspective\n if (next.returnMs !== undefined) runtime.returnMs = next.returnMs\n paint(spring.get())\n },\n destroy() {\n playing = false\n playAbort.current.abort()\n if (idleTimer) clearTimeout(idleTimer)\n if (gateRaf) cancelAnimationFrame(gateRaf)\n spring.destroy()\n options.plane.querySelector(\"[data-slot='folio-blur-veil']\")?.remove()\n media.removeEventListener(\"change\", applyReduce)\n wheelTarget.removeEventListener(\"wheel\", onWheel)\n options.scroller.removeEventListener(\"scroll\", onScroll)\n window.removeEventListener(\"resize\", onResize)\n window.removeEventListener(FOLIO_PLAY, onPlay)\n options.plane.style.transform = \"\"\n options.plane.style.filter = \"\"\n options.plane.style.willChange = \"\"\n options.plane.style.backfaceVisibility = \"\"\n options.plane.style.transformOrigin = \"\"\n },\n }\n}\n\nexport type FolioProps = FolioRuntimeOptions & {\n className?: string\n /** Page content that leans while you scroll. */\n children?: ReactNode\n /**\n * Bind to the window and tilt `contentSelector` instead of wrapping\n * children. Default `false` — this component *is* the scroller.\n */\n windowScroll?: boolean\n /**\n * Element to tilt when `windowScroll` is set.\n * Default `[data-folio-page]`.\n */\n contentSelector?: string\n /** Accessible name for the tilting page. */\n label?: string\n /**\n * Optional id for docs demos. `playFolioDemo({ target })` only\n * animates instances whose `demoId` matches.\n */\n demoId?: string\n}\n\n/**\n * The whole page leans in perspective while you scroll, with a matching\n * blur. When scroll stops, it springs to flat.\n */\nexport function Folio({\n className,\n children,\n blur = 4,\n perspective = 1000,\n returnMs = 520,\n windowScroll = false,\n contentSelector = \"[data-folio-page]\",\n label = \"Tilting page\",\n demoId,\n}: FolioProps) {\n const scrollerRef = useRef(null)\n const planeRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const scroller = windowScroll ? window : scrollerRef.current\n const plane = windowScroll\n ? document.querySelector(contentSelector)\n : planeRef.current\n if (!scroller || !plane) return\n\n instanceRef.current = createFolio({\n plane,\n scroller,\n demoId,\n blur,\n perspective,\n returnMs,\n })\n\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; bind once per scroller mode.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [windowScroll, contentSelector, demoId])\n\n useEffect(() => {\n instanceRef.current?.setOptions({ blur, perspective, returnMs })\n }, [blur, perspective, returnMs])\n\n if (windowScroll) return null\n\n return (\n
\n
\n {children}\n
\n
\n )\n}\n",
+ "type": "registry:ui",
+ "target": "components/ui/folio.tsx"
+ }
+ ],
+ "type": "registry:ui"
+}
\ No newline at end of file
diff --git a/public/r/registry.json b/public/r/registry.json
index 055e75d..d26f4ef 100644
--- a/public/r/registry.json
+++ b/public/r/registry.json
@@ -81,6 +81,32 @@
],
"type": "registry:ui"
},
+ {
+ "name": "folio",
+ "title": "Folio",
+ "description": "The whole page leans back in perspective while you scroll, blurs, then springs flat when you stop.",
+ "files": [
+ {
+ "path": "registry/folio/folio.tsx",
+ "type": "registry:ui",
+ "target": "components/ui/folio.tsx"
+ }
+ ],
+ "type": "registry:ui"
+ },
+ {
+ "name": "folio-svelte",
+ "title": "Folio",
+ "description": "The whole page leans back in perspective while you scroll, blurs, then springs flat when you stop.",
+ "files": [
+ {
+ "path": "registry/folio/folio.svelte",
+ "type": "registry:file",
+ "target": "src/lib/components/ui/folio.svelte"
+ }
+ ],
+ "type": "registry:ui"
+ },
{
"name": "gooey-color-picker",
"title": "Gooey Color Picker",
diff --git a/registry.json b/registry.json
index d865091..bd8a0c2 100644
--- a/registry.json
+++ b/registry.json
@@ -6,6 +6,7 @@
"registry/ascii-fluid/registry.json",
"registry/ascii-logo/registry.json",
"registry/dithered-404/registry.json",
+ "registry/folio/registry.json",
"registry/gooey-color-picker/registry.json",
"registry/live-orb/registry.json",
"registry/logo-burst/registry.json",
diff --git a/registry/folio/folio-demo.tsx b/registry/folio/folio-demo.tsx
new file mode 100644
index 0000000..aeeb5dc
--- /dev/null
+++ b/registry/folio/folio-demo.tsx
@@ -0,0 +1,271 @@
+"use client"
+
+import { useState } from "react"
+import { RiArrowDownLine } from "@remixicon/react"
+
+import { Button } from "@/components/ui/button"
+import {
+ ComponentControls,
+ ControlSlider,
+} from "@/components/component-controls"
+import { ComponentPreview } from "@/components/component-preview"
+import { usePreviewProps } from "@/hooks/use-preview-props"
+import { Folio, playFolioDemo } from "@/registry/folio/folio"
+
+const PREVIEW_DEMO_ID = "folio-preview"
+
+function FolioPage() {
+ return (
+
+
+ Scroll
+
+
+ The page leans back
+
+
+ Wheel through this sheet. Scroll down and it tips one way; scroll
+ up and it tips the other. Stop, and it springs to flat.
+
+
+
+
+ Issue 23
+
+
+ A long sheet, on purpose
+
+
+ Folio is not a card trick. It is the whole page as a plane — the
+ same surface you would set type on, tilted by the speed of your
+ hand. The copy below is here so the lean has miles of paper to
+ work against.
+
+
+ Keep the wheel moving. Each stretch of this sheet can show the
+ same move: down-lean, idle, spring. Then the opposite on the way
+ back up.
+
+
+
+
+
Hinge
+
+ Origin follows the visible viewport. On the way down the hinge
+ sits at the bottom of the frame and the top edge is the one
+ that travels. On the way up it flips: hinge at the top, the
+ foot of the page is what moves.
+
+
+ Blur lives on that traveling edge. The hinge stays sharp. When
+ the spring settles, both go to zero.
+
+
+
+
+
+ Velocity, not distance. How far you have scrolled does not
+ matter. How fast you are moving does.
+
+
+ From the notes
+
+
+
+
+
+
Down
+
+ Flick toward the colophon. The sheet tips away from the
+ reading line, blur gathering at the head. Hold the wheel and
+ the pose holds with you.
+
+
+
+
Up
+
+ Reverse the stroke. The lean inverts. Same spring home when
+ you let go — the page does not care which way you came from.
+
+
+
+
+
+
+ Room to read the move
+
+
+ Short demos lie. A tilt that looks clever on one screen goes
+ cheap when the page has nowhere to go. This sheet is long so
+ you can forget the control chrome, travel, and still find the
+ same lean waiting.
+
+
+ Try a slow crawl. Then a hard flick. Slow travel barely leans;
+ a flick reaches about sixteen degrees. The spring is what makes
+ them feel different.
+
+
+
+
+
+ Midway
+
+
+ You are past the fold and the type is still the same size. That
+ is the point. Folio does not zoom the story. It only changes
+ the plane the story sits on.
+
+
+ If the lean vanished here, the page would feel broken. It
+ should feel like paper that remembers your hand.
+
+
+
+
+
+ What to ignore
+
+
+ Ignore the preview chrome. Ignore the sliders for a minute.
+ Treat this as a site: a headline, a few columns of notes, a
+ quiet end. The effect is only honest if it can live under real
+ copy.
+
+
+ Reduced motion turns it off. That is also honest. A plane that
+ cannot sit still is a problem, not a feature.
+
+
+
+
+
Still going
+
+ Another screen of travel. The lean should not get tired. If it
+ does, the mapping is wrong — we keyed it to speed so the bottom
+ of the essay can still surprise you.
+
+
+ Near the end the down-lean fades. That is deliberate. A page
+ should be allowed to rest on the last line.
+
+
+
+
+
+ The last stretch
+
+
+ Keep scrolling. The move is the same the whole way until the
+ floor. Then it lets go, and the sheet is just a sheet again.
+
+
+ Scroll back up from here and the other tilt arrives — hinge
+ flipped, blur on the foot of the page, same spring when you
+ stop.
+
+
+
+
+ Almost there. One more quiet field of type so the ending has a
+ runway, not a cliff.
+
+
+
+ End of the sheet. The down-lean lets go here. Scroll back up and
+ it tips the other way.
+