From 60dfcefdec1c5f58294a1431999a4ee3989e30f6 Mon Sep 17 00:00:00 2001 From: arzafran Date: Mon, 8 Jun 2026 16:18:46 -0300 Subject: [PATCH 1/2] feat: add useScrollTrigger, useTransform, useEffectEvent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the scroll hooks from Clément Roche's #11 onto the packages/hamo structure: - useEffectEvent: promote the stable-callback helper to a public hook; use-debounce now consumes it (removes the private useStableCallback duplicate). - useTransform / TransformProvider: context-based transform accumulation for parallax compensation. Zero dependencies. - useScrollTrigger + Debugger: scroll-progress tracking with GSAP-style position syntax, optional Lenis integration (graceful native-scroll fallback), exposed with the debug overlay behind the hamo/scroll-trigger/debugger subpath. lenis is an optional peer dependency; the debug store's emitter is inlined so hamo keeps zero runtime dependencies. Playground gains a /scroll-trigger demo. Co-Authored-By: Clément Roche --- bun.lock | 7 + packages/hamo/biome.json | 12 + packages/hamo/package.json | 19 +- packages/hamo/src/index.ts | 9 + packages/hamo/src/use-debounce/index.ts | 21 +- packages/hamo/src/use-effect-event/README.md | 114 +++ packages/hamo/src/use-effect-event/index.ts | 16 + .../hamo/src/use-scroll-trigger/README.md | 369 +++++++++ .../hamo/src/use-scroll-trigger/debugger.tsx | 207 +++++ packages/hamo/src/use-scroll-trigger/index.ts | 347 +++++++++ packages/hamo/src/use-scroll-trigger/store.ts | 60 ++ packages/hamo/src/use-transform/README.md | 135 ++++ packages/hamo/src/use-transform/index.tsx | 228 ++++++ packages/hamo/tsdown.config.ts | 8 +- playground/react/scroll-trigger-app.tsx | 730 ++++++++++++++++++ playground/react/scroll-trigger-style.css | 434 +++++++++++ playground/www/layouts/Layout.astro | 1 + playground/www/pages/scroll-trigger.astro | 10 + 18 files changed, 2707 insertions(+), 20 deletions(-) create mode 100644 packages/hamo/src/use-effect-event/README.md create mode 100644 packages/hamo/src/use-effect-event/index.ts create mode 100644 packages/hamo/src/use-scroll-trigger/README.md create mode 100644 packages/hamo/src/use-scroll-trigger/debugger.tsx create mode 100644 packages/hamo/src/use-scroll-trigger/index.ts create mode 100644 packages/hamo/src/use-scroll-trigger/store.ts create mode 100644 packages/hamo/src/use-transform/README.md create mode 100644 packages/hamo/src/use-transform/index.tsx create mode 100644 playground/react/scroll-trigger-app.tsx create mode 100644 playground/react/scroll-trigger-style.css create mode 100644 playground/www/pages/scroll-trigger.astro diff --git a/bun.lock b/bun.lock index 3b1b54a..1f9d19d 100644 --- a/bun.lock +++ b/bun.lock @@ -14,14 +14,19 @@ "@testing-library/react": "^16.1.0", "@types/react": "^19.0.0", "happy-dom": "^20.0.0", + "lenis": "^1.3.19", "react": "^19.0.0", "react-dom": "^19.0.0", "tsdown": "^0.21.4", "typescript": "^5.4.5", }, "peerDependencies": { + "lenis": ">=1.3.0", "react": ">=18.0.0", }, + "optionalPeers": [ + "lenis", + ], }, "playground": { "name": "playground", @@ -656,6 +661,8 @@ "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + "lenis": ["lenis@1.3.23", "", { "peerDependencies": { "@nuxt/kit": ">=3.0.0", "react": ">=17.0.0", "vue": ">=3.0.0" }, "optionalPeers": ["@nuxt/kit", "react", "vue"] }, "sha512-YxYq3TJqj9sJNv0V9SkyQHejt14xwyIwgDaaMK89Uf9SxQfIszu+gTQSSphh6BWlLTNVKvvXAGkg+Zf+oFIevg=="], + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], "lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="], diff --git a/packages/hamo/biome.json b/packages/hamo/biome.json index c41867f..e1c8dda 100644 --- a/packages/hamo/biome.json +++ b/packages/hamo/biome.json @@ -36,6 +36,18 @@ } } }, + "overrides": [ + { + "includes": ["**/use-scroll-trigger/debugger.tsx"], + "linter": { + "rules": { + "a11y": { + "noStaticElementInteractions": "off" + } + } + } + } + ], "javascript": { "formatter": { "quoteStyle": "single", diff --git a/packages/hamo/package.json b/packages/hamo/package.json index 8edd2d7..05fd4a5 100644 --- a/packages/hamo/package.json +++ b/packages/hamo/package.json @@ -18,6 +18,16 @@ "default": "./dist/hamo.cjs" } }, + "./scroll-trigger/debugger": { + "import": { + "types": "./dist/use-scroll-trigger/debugger.d.ts", + "default": "./dist/use-scroll-trigger/debugger.mjs" + }, + "require": { + "types": "./dist/use-scroll-trigger/debugger.d.cts", + "default": "./dist/use-scroll-trigger/debugger.cjs" + } + }, "./package.json": "./package.json" }, "files": [ @@ -71,13 +81,20 @@ "@testing-library/react": "^16.1.0", "@types/react": "^19.0.0", "happy-dom": "^20.0.0", + "lenis": "^1.3.19", "react": "^19.0.0", "react-dom": "^19.0.0", "tsdown": "^0.21.4", "typescript": "^5.4.5" }, "peerDependencies": { - "react": ">=18.0.0" + "react": ">=18.0.0", + "lenis": ">=1.3.0" + }, + "peerDependenciesMeta": { + "lenis": { + "optional": true + } }, "overrides": { "yaml": "^2.9.0" diff --git a/packages/hamo/src/index.ts b/packages/hamo/src/index.ts index 4a7a1e1..2339b4c 100644 --- a/packages/hamo/src/index.ts +++ b/packages/hamo/src/index.ts @@ -4,6 +4,7 @@ export { useDebouncedState, useTimeout, } from './use-debounce' +export { useEffectEvent } from './use-effect-event' export { useIntersectionObserver } from './use-intersection-observer' export { useLazyState } from './use-lazy-state' export { useMediaQuery } from './use-media-query' @@ -11,4 +12,12 @@ export { useObjectFit } from './use-object-fit' export type { Rect } from './use-rect' export { useRect } from './use-rect' export { useResizeObserver } from './use-resize-observer' +export type { UseScrollTriggerOptions } from './use-scroll-trigger' +export { useScrollTrigger } from './use-scroll-trigger' +export type { Transform, TransformRef } from './use-transform' +export { + TransformContext, + TransformProvider, + useTransform, +} from './use-transform' export { useWindowSize } from './use-window-size' diff --git a/packages/hamo/src/use-debounce/index.ts b/packages/hamo/src/use-debounce/index.ts index 1ff6d73..4723a71 100644 --- a/packages/hamo/src/use-debounce/index.ts +++ b/packages/hamo/src/use-debounce/index.ts @@ -7,6 +7,7 @@ import { useRef, useState, } from 'react' +import { useEffectEvent } from '../use-effect-event' export type DebouncedFunction void> = (( ...args: Parameters @@ -44,28 +45,12 @@ function timeout(callback: (...args: any[]) => void, delay: number) { return () => clearTimeout(timeout) } -// A stable-identity callback that always invokes the latest `callback`. Named -// to avoid shadowing React's reserved `useEffectEvent` API — this is a plain -// ref-backed wrapper with none of that hook's call-site restrictions. -function useStableCallback any>(callback: T): T { - const callbackRef = useRef(callback) - callbackRef.current = callback - - const [memoizedCallback] = useState( - () => - (...args: Parameters) => - callbackRef.current(...args) - ) - - return memoizedCallback as T -} - export function useDebouncedEffect( _callback: () => void, delay: number, deps: DependencyList = [] ) { - const callback = useStableCallback(_callback) + const callback = useEffectEvent(_callback) useEffect(() => { return timeout(() => callback(), delay) @@ -77,7 +62,7 @@ export function useDebouncedCallback( delay: number, deps: DependencyList = [] ) { - const callback = useStableCallback(_callback) + const callback = useEffectEvent(_callback) const timeoutRef = useRef | null>(null) diff --git a/packages/hamo/src/use-effect-event/README.md b/packages/hamo/src/use-effect-event/README.md new file mode 100644 index 0000000..6d14fac --- /dev/null +++ b/packages/hamo/src/use-effect-event/README.md @@ -0,0 +1,114 @@ +# useEffectEvent + +A polyfill for React's experimental [`useEffectEvent`](https://react.dev/reference/react/useEffectEvent) hook. Returns a stable function reference that always calls the latest version of your callback, without needing to be listed in effect dependency arrays. + +React's `useEffectEvent` is still experimental and not available in any stable release. This implementation provides the same core behavior for React 17+ projects. + +## Usage + +```jsx +import { useEffectEvent } from 'hamo' + +function Chat({ url, onMessage }) { + const handleMessage = useEffectEvent(onMessage) + + useEffect(() => { + const ws = new WebSocket(url) + ws.addEventListener('message', handleMessage) + + return () => ws.close() + }, [url]) // handleMessage doesn't need to be in deps +} +``` + +## Parameters + +- `callback`: The function to wrap. Can accept any arguments and return any value. + +## Return Value + +A stable function with the same signature as your callback. The identity never changes across renders, but calling it always invokes the latest `callback` from the most recent render. + +## How It Works + +The hook stores your callback in a ref (updated every render) and returns a memoized wrapper created once via lazy `useState`. The wrapper delegates to the ref on each call, so: + +- The returned function has a **stable identity** (same reference every render) +- It always reads the **latest props and state** at call time +- It can safely be omitted from effect dependency arrays + +## Differences from React's Experimental Version + +| | React `useEffectEvent` | hamo `useEffectEvent` | +|---|---|---| +| Availability | Experimental, not in stable React | React 17+ | +| Identity | Intentionally unstable (changes every render) | Stable (same reference every render) | +| Callable from | Effects and other Effect Events only | Anywhere (effects, event handlers, callbacks) | +| Lint enforcement | ESLint plugin enforces constraints | No restrictions | + +The stable identity in hamo's version is a practical trade-off — it makes the hook more versatile (usable in event handlers, passed as props) while still solving the core problem of reading latest values without re-triggering effects. + +## Examples + +### Interval with Latest State + +```jsx +import { useEffect, useState } from 'react' +import { useEffectEvent } from 'hamo' + +function Counter() { + const [count, setCount] = useState(0) + const [step, setStep] = useState(1) + + const onTick = useEffectEvent(() => { + setCount((c) => c + step) // always reads latest step + }) + + useEffect(() => { + const id = setInterval(onTick, 1000) + return () => clearInterval(id) + }, []) // no need to restart interval when step changes + + return ( +
+

{count}

+ setStep(Number(e.target.value))} + /> +
+ ) +} +``` + +### Effect Without Unnecessary Re-runs + +```jsx +import { useEffect } from 'react' +import { useEffectEvent } from 'hamo' + +function Logger({ data, onLog }) { + const log = useEffectEvent(onLog) + + useEffect(() => { + log(data) // always calls latest onLog + }, [data]) // effect only re-runs when data changes, not when onLog changes +} +``` + +### Scroll Listener with Latest Callback + +```jsx +import { useEffect } from 'react' +import { useEffectEvent } from 'hamo' + +function useScroll(callback) { + const handler = useEffectEvent(callback) + + useEffect(() => { + window.addEventListener('scroll', handler) + return () => window.removeEventListener('scroll', handler) + }, []) // listener attached once, always calls latest callback +} +``` diff --git a/packages/hamo/src/use-effect-event/index.ts b/packages/hamo/src/use-effect-event/index.ts new file mode 100644 index 0000000..30f8654 --- /dev/null +++ b/packages/hamo/src/use-effect-event/index.ts @@ -0,0 +1,16 @@ +import { useRef, useState } from 'react' + +export function useEffectEvent any>( + callback: T +): T { + const callbackRef = useRef(callback) + callbackRef.current = callback + + const [memoizedCallback] = useState( + () => + (...args: Parameters) => + callbackRef.current(...args) + ) + + return memoizedCallback as T +} diff --git a/packages/hamo/src/use-scroll-trigger/README.md b/packages/hamo/src/use-scroll-trigger/README.md new file mode 100644 index 0000000..0f27a85 --- /dev/null +++ b/packages/hamo/src/use-scroll-trigger/README.md @@ -0,0 +1,369 @@ +# useScrollTrigger + +A high-performance, transparent scroll progress tracker for React. + +GSAP ScrollTrigger is powerful but it's a black box — you hand it an element and hope for the best. `useScrollTrigger` gives you the same scroll-triggered progress tracking with full visibility into how it works, what it reads, and when it fires. + +## Philosophy + +Every design decision serves performance and transparency: + +- **No per-frame DOM reads.** Element positions are computed once (via `useRect`) and cached. GSAP calls `getBoundingClientRect()` on every scroll frame for every trigger — that forces layout recalculation and kills performance on pages with dozens of scroll-driven elements. +- **No React re-renders.** Progress updates flow through `useLazyState` and refs, never through `setState`. Your scroll callbacks fire at 60fps without touching the React render cycle. +- **No magic.** You get `progress`, `direction`, `isActive`, and `steps` — raw values you wire up yourself. No hidden timeline binding, no implicit CSS changes, no side effects you didn't ask for. +- **Composable.** Built on `useRect`, `useLazyState`, `useEffectEvent`, and `useTransform` — hooks you can use independently. If `useScrollTrigger` doesn't do what you want, you have the building blocks to make your own. + +### TransformProvider: the trade-off + +Because positions are cached (not read per-frame), programmatic transforms on a parent element (like parallax `translateY`) would make the cached position stale. `TransformProvider` solves this — you tell it what you moved, and child scroll triggers compensate automatically. + +This is an explicit trade-off: one extra component wrapper in exchange for zero layout thrashing. On a scrollytelling page with 20+ triggers, this is the difference between smooth and janky. + +## Usage + +```jsx +import { useScrollTrigger } from 'hamo' + +function FadeIn() { + const elementRef = useRef(null) + + const [setRef] = useScrollTrigger({ + onProgress: ({ progress }) => { + elementRef.current.style.opacity = `${progress}` + }, + }) + + return ( +
{ elementRef.current = node; setRef(node) }}> + Fades in as you scroll +
+ ) +} +``` + +## Parameters + +### Options + +- `start`: (string, default: `'bottom bottom'`) Start position: `"element-position viewport-position"`. +- `end`: (string, default: `'top top'`) End position: `"element-position viewport-position"`. +- `offset`: (number, default: `0`) Pixel offset added to element positions. +- `disabled`: (boolean, default: `false`) Disables the scroll trigger. +- `onEnter`: (function) Called when entering the trigger zone. Receives `{ progress, direction }`. +- `onLeave`: (function) Called when leaving the trigger zone. Receives `{ progress, direction }`. +- `onProgress`: (function) Called on every scroll update. Receives `{ height, isActive, progress, lastProgress, direction, steps }`. +- `steps`: (number, default: `1`) Subdivides progress into N discrete sub-ranges. +- `debug`: (boolean | string, default: `false`) Registers the trigger in the debug store. Pass a string to use as label in the Debugger minimap. +- `rect`: (Rect) External rect from `useRect` — pass this to share a single `useRect` across multiple triggers on the same element. + +### Dependencies + +- `deps`: (array, default: `[]`) Dependencies that trigger recalculation. + +## Return Value + +Returns `[setRef, rect]`: + +1. `setRef` — Callback ref to attach to the target element. +2. `rect` — The element's current rect (from `useRect` internally). + +## Position Syntax + +Format: `"element-position viewport-position"` + +| Keyword | Element | Viewport | +|---------|---------|----------| +| `top` | Top edge | Top of viewport | +| `center` | Vertical center | Center of viewport | +| `bottom` | Bottom edge | Bottom of viewport | +| `number` | Pixel offset from top | Pixel offset from top | + +### Common Combinations + +| Start / End | Description | +|-------------|-------------| +| `'bottom bottom'` / `'top top'` | Full element traversal (default) | +| `'bottom bottom'` / `'top center'` | From entering viewport to reaching center | +| `'center center'` / `'top top'` | From center alignment to reaching top | + +## onProgress Callback Data + +| Property | Type | Description | +|----------|------|-------------| +| `progress` | `number` | Clamped progress from 0 to 1 | +| `lastProgress` | `number` | Previous progress value | +| `direction` | `1 \| -1` | Scroll direction (1 = down, -1 = up) | +| `isActive` | `boolean` | Whether progress is between 0 and 1 | +| `height` | `number` | Scroll distance in pixels between start and end | +| `steps` | `number[]` | Array of per-step progress values | + +## How It Works + +``` +useScrollTrigger + ├── useRect() → computes element position once, caches it + ├── useWindowSize() → viewport height for position keywords + ├── useTransform() → reads parent transform offset (if any) + ├── useLenis() → subscribes to Lenis scroll (or falls back to native) + ├── useLazyState() → tracks progress without re-renders + └── useEffectEvent() → stable callbacks, no effect churn + +On every scroll tick: + 1. Read scroll position (from Lenis or window.scrollY) + 2. Subtract parent transform offset (from TransformProvider) + 3. Map scroll into [0, 1] progress using cached element position + 4. Fire onEnter/onLeave/onProgress if progress changed + 5. Zero DOM reads. Zero re-renders. +``` + +## Examples + +### Enter / Leave with Direction + +```jsx +import { useScrollTrigger } from 'hamo' + +function Section() { + const [setRef] = useScrollTrigger({ + onEnter: ({ direction }) => { + console.log(direction === 1 ? 'Entered scrolling down' : 'Entered scrolling up') + }, + onLeave: ({ direction }) => { + console.log(direction === 1 ? 'Left scrolling down' : 'Left scrolling up') + }, + }) + + return
Tracked section
+} +``` + +### Steps for Staggered Animations + +```jsx +import { useRef } from 'react' +import { useScrollTrigger } from 'hamo' + +function StaggeredList() { + const itemRefs = useRef([]) + + const [setRef] = useScrollTrigger({ + steps: 5, + onProgress: ({ steps }) => { + steps.forEach((stepProgress, i) => { + if (itemRefs.current[i]) { + itemRefs.current[i].style.opacity = `${stepProgress}` + itemRefs.current[i].style.transform = `translateY(${(1 - stepProgress) * 20}px)` + } + }) + }, + }) + + return ( +
    + {Array.from({ length: 5 }).map((_, i) => ( +
  • { itemRefs.current[i] = el }}> + Item {i + 1} +
  • + ))} +
+ ) +} +``` + +### With Lenis + +When Lenis is installed, the hook automatically uses it. No configuration needed. + +```jsx +import { ReactLenis } from 'lenis/react' +import { useScrollTrigger } from 'hamo' + +function App() { + return ( + + + + ) +} + +function AnimatedSection() { + const elementRef = useRef(null) + + const [setRef] = useScrollTrigger({ + onProgress: ({ progress }) => { + elementRef.current.style.transform = `translateX(${progress * 100}px)` + }, + }) + + return ( +
{ elementRef.current = node; setRef(node) }}> + Slides in with smooth scroll +
+ ) +} +``` + +### CSS Custom Property + +```jsx +import { useScrollTrigger } from 'hamo' + +function ParallaxSection() { + const elementRef = useRef(null) + + const [setRef] = useScrollTrigger({ + onProgress: ({ progress }) => { + elementRef.current.style.setProperty('--progress', `${progress}`) + }, + }) + + return ( +
{ elementRef.current = node; setRef(node) }} + style={{ transform: 'translateY(calc(var(--progress, 0) * -50px))' }} + > + Parallax content +
+ ) +} +``` + +### With TransformProvider (Parallax Compensation) + +When a parent is translated programmatically, child scroll triggers need to know. Wrap the parent in `TransformProvider` and report your transforms — children compensate automatically. + +```jsx +import { useRef } from 'react' +import { useScrollTrigger, TransformProvider } from 'hamo' +import type { TransformRef } from 'hamo' + +function ParallaxWrapper({ children }) { + const transformRef = useRef(null) + const elementRef = useRef(null) + + const [setRef] = useScrollTrigger({ + onProgress: ({ progress }) => { + const y = (progress - 0.5) * -100 + transformRef.current?.setTranslate(0, y) + elementRef.current.style.transform = `translateY(${y}px)` + }, + }) + + return ( + +
{ elementRef.current = node; setRef(node) }}> + {children} +
+
+ ) +} + +function ChildTrigger() { + const [setRef] = useScrollTrigger({ + onProgress: ({ progress }) => { + // Accurate despite parent parallax — no getBoundingClientRect needed + console.log('progress:', progress) + }, + }) + + return
Child content
+} + +function Page() { + return ( + + + + ) +} +``` + +### Progressive Text Reveal + +```jsx +import { useRef } from 'react' +import { useScrollTrigger } from 'hamo' + +function TextReveal({ text }) { + const containerRef = useRef(null) + const words = text.split(' ') + + const [setRef] = useScrollTrigger({ + start: 'bottom bottom', + end: 'center center', + steps: words.length, + onProgress: ({ steps }) => { + if (!containerRef.current) return + const spans = containerRef.current.querySelectorAll('span') + spans.forEach((span, i) => { + span.style.opacity = `${steps[i]}` + }) + }, + }) + + return ( +

{ containerRef.current = node; setRef(node) }}> + {words.map((word, i) => ( + + {word}{' '} + + ))} +

+ ) +} +``` + +## Debugger + +A minimap overlay that visualizes all active scroll triggers on the page. Each trigger with `debug` enabled appears as a colored rectangle (element position) and a bar (start/end range). Hover a rectangle to see a tooltip with the trigger's id, start/end positions, progress, and active state. + +```jsx +import { Debugger } from 'hamo/scroll-trigger' + +function App() { + return ( + <> + + {/* your content */} + + ) +} +``` + +### Props + +- `theme`: (`'light' | 'dark'`, default: `'dark'`) Color theme. + +### How it works + +Triggers with `debug` enabled register themselves in a shared store. The Debugger subscribes to that store and renders a fixed minimap that: + +- Mirrors the page body shape using the body's aspect ratio +- Scrolls in sync via a CSS custom property (`--p`) +- Shows each trigger's element as a colored rectangle, offset by `translateY` from `TransformProvider` +- Shows each trigger's start/end scroll range as a colored bar aligned to its element +- Displays a tooltip on hover with trigger details (id, start, end, progress, active) + +### Enabling debug on a trigger + +```jsx +const [setRef] = useScrollTrigger({ + start: 'bottom bottom', + end: 'top top', + debug: 'my-section', // label shown in tooltip + onProgress: ({ progress }) => { /* ... */ }, +}) +``` + +## vs GSAP ScrollTrigger + +| | GSAP ScrollTrigger | useScrollTrigger | +|---|---|---| +| DOM reads per frame | `getBoundingClientRect()` on every tick | Zero — positions cached via `useRect` | +| React re-renders | N/A (not React-aware) | Zero — updates via refs and `useLazyState` | +| Lenis integration | Manual `scrollerProxy` setup | Automatic | +| Transform awareness | Implicit (reads DOM) | Explicit via `TransformProvider` | +| Bundle size | ~55KB (GSAP core + plugin) | ~1KB (inlined in hamo) | +| Pinning, scrub, snap | Yes | No — use GSAP for those | +| License | Custom (restrictions on some commercial use) | MIT | +| Transparency | Black box | Every hook is composable and inspectable | diff --git a/packages/hamo/src/use-scroll-trigger/debugger.tsx b/packages/hamo/src/use-scroll-trigger/debugger.tsx new file mode 100644 index 0000000..ac80775 --- /dev/null +++ b/packages/hamo/src/use-scroll-trigger/debugger.tsx @@ -0,0 +1,207 @@ +'use client' + +import { useLenis } from 'lenis/react' +import { useEffect, useRef, useState, useSyncExternalStore } from 'react' +import { useWindowSize } from '../use-window-size' +import { scrollTriggerStore } from './store' + +const COLORS = [ + '#3b82f6', + '#a855f7', + '#f59e0b', + '#14b8a6', + '#f97316', + '#ec4899', +] + +const BAR_W = 4 + +interface DebuggerProps { + theme?: 'light' | 'dark' +} + +export function Debugger({ theme = 'dark' }: DebuggerProps) { + const fg = theme === 'dark' ? '0,0,0' : '255,255,255' + const bg = theme === 'dark' ? '255,255,255' : '0,0,0' + const [hovered, setHovered] = useState(null) + const ref = useRef(null) + const lenis = useLenis() + const { width: ww = 0, height: wh = 0 } = useWindowSize() + + const triggers = useSyncExternalStore( + (cb) => scrollTriggerStore.subscribe(cb), + () => scrollTriggerStore.getSnapshot(), + () => scrollTriggerStore.getSnapshot() + ) + + useEffect(() => { + function update() { + if (!ref.current) return + const docH = lenis + ? lenis.limit + wh + : document.documentElement.scrollHeight + const scroll = lenis ? lenis.scroll : window.scrollY + const p = docH > wh ? scroll / (docH - wh) : 0 + ref.current.style.setProperty('--p', p.toString()) + } + + update() + + if (lenis) { + lenis.on('scroll', update) + return () => { + lenis.off('scroll', update) + } + } + + window.addEventListener('scroll', update, { passive: true }) + return () => { + window.removeEventListener('scroll', update) + } + }, [lenis, wh]) + + useEffect(() => { + if (!ref.current) return + const ro = new ResizeObserver(([e]) => { + if (!e || !ref.current) return + ref.current.style.setProperty( + '--br', + (e.contentRect.width / e.contentRect.height).toFixed(4) + ) + }) + ro.observe(document.body) + return () => ro.disconnect() + }, []) + + const vr = ww && wh ? ww / wh : 1 + const h = 200 / vr + const docH = lenis + ? lenis.limit + wh + : typeof document !== 'undefined' + ? document.documentElement.scrollHeight + : 1 + + return ( +
+ {/* Body */} +
+ {triggers.map((t, i) => { + const color = COLORS[i % COLORS.length] + const top = ((t.rect.top + t.translateY) / docH) * 100 + const left = (t.rect.left / ww) * 100 + const w = (t.rect.width / ww) * 100 + const rh = (t.rect.height / docH) * 100 + const startPct = ((t.startPx + t.translateY) / docH) * 100 + const endPct = ((t.endPx + t.translateY) / docH) * 100 + const barTop = Math.min(startPct, endPct) + const barH = Math.abs(endPct - startPct) + + const isHovered = hovered === t.id + + return ( +
+ {/* Element rectangle */} +
setHovered(t.id)} + onMouseLeave={() => setHovered(null)} + style={{ + position: 'absolute', + top: `${top}%`, + left: `${left}%`, + width: `${w}%`, + height: `${rh}%`, + border: `1px solid ${color}`, + opacity: isHovered ? 1 : t.isActive ? 0.8 : 0.2, + transition: 'opacity 150ms', + backgroundColor: `rgba(${fg},0.1)`, + cursor: 'default', + zIndex: isHovered ? 1 : 0, + }} + > + {/* Tooltip */} + {isHovered && ( +
+ {t.id} +
+ start: {t.start} ({Math.round(t.startPx)}px) +
+ end: {t.end} ({Math.round(t.endPx)}px) +
+ progress: {t.progress.toFixed(3)} +
+ active: {t.isActive ? 'true' : 'false'} +
+ )} +
+ {/* Bar */} +
+
+ ) + })} +
+ + {/* Border */} +
+
+ ) +} diff --git a/packages/hamo/src/use-scroll-trigger/index.ts b/packages/hamo/src/use-scroll-trigger/index.ts new file mode 100644 index 0000000..225051b --- /dev/null +++ b/packages/hamo/src/use-scroll-trigger/index.ts @@ -0,0 +1,347 @@ +'use client' + +import { useLenis } from 'lenis/react' +import { useEffect, useId, useRef } from 'react' +import { useEffectEvent } from '../use-effect-event' +import { useLazyState } from '../use-lazy-state' +import { type Rect, useRect } from '../use-rect' +import { useTransform } from '../use-transform' +import { useWindowSize } from '../use-window-size' +import { scrollTriggerStore } from './store' + +// Math utilities (inlined to avoid external dependency) +function clamp(min: number, input: number, max: number): number { + return Math.max(min, Math.min(input, max)) +} + +function mapRange( + inMin: number, + inMax: number, + input: number, + outMin: number, + outMax: number +): number { + return ((input - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin +} + +function isNumber(value: unknown): value is number { + return typeof value === 'number' || !Number.isNaN(value) +} + +export function modulo(n: number, d: number) { + if (d === 0) return n + if (d < 0) return Number.NaN + return ((n % d) + d) % d +} + +type TriggerPosition = 'top' | 'center' | 'bottom' | number +type TriggerPositionCombination = `${TriggerPosition} ${TriggerPosition}` + +export type UseScrollTriggerOptions = { + /** External rect from useRect — pass this to share a single useRect across multiple triggers on the same element */ + rect?: Rect + /** Start position: "element-position viewport-position" (default: "bottom bottom") */ + start?: TriggerPositionCombination + /** End position: "element-position viewport-position" (default: "top top") */ + end?: TriggerPositionCombination + /** Pixel offset added to element positions */ + offset?: number + /** Disable the scroll trigger */ + disabled?: boolean + /** Called when element enters the trigger zone */ + onEnter?: (data: { progress: number; direction: 1 | -1 }) => void + /** Called when element leaves the trigger zone */ + onLeave?: (data: { progress: number; direction: 1 | -1 }) => void + /** Called on every scroll progress update */ + onProgress?: (data: { + height: number + isActive: boolean + progress: number + lastProgress: number + direction: 1 | -1 + steps: number[] + }) => void + /** Number of discrete steps to subdivide progress into */ + steps?: number + /** Enable debug mode — registers this trigger to the Minimap. Pass a string to use as label. */ + debug?: boolean | string +} + +/** + * Hook for creating scroll-based animations and triggers. + * + * Provides scroll-triggered progress tracking with GSAP ScrollTrigger-like + * position syntax. Integrates with Lenis when available, falls back to + * native scroll events. + * + * Position format: "element-position viewport-position" + * Available positions: 'top', 'center', 'bottom', or pixel values + * + * @param options - Configuration options + * @param deps - Dependencies that trigger recalculation + * + * @returns [setRef, rect] - A ref setter (undefined if external rect provided) and the element's rect + * + * @example + * ```tsx + * // Basic usage — creates its own useRect internally + * const [setRef] = useScrollTrigger({ + * onProgress: ({ progress }) => console.log(progress), + * }) + * return
...
+ * ``` + * + * @example + * ```tsx + * // Shared rect — multiple triggers on the same element, single useRect + * const [setRef, rect] = useRect() + * useScrollTrigger({ rect, end: 'center center', onEnter: handleEnter }) + * useScrollTrigger({ rect, start: 'center center', onProgress: handleParallax }) + * return
...
+ * ``` + */ +export function useScrollTrigger( + { + rect: externalRect, + start = 'bottom bottom', + end = 'top top', + offset = 0, + disabled = false, + onEnter, + onLeave, + onProgress, + steps = 1, + debug = false, + }: UseScrollTriggerOptions = {}, + deps: unknown[] = [] +) { + const [setRectRef, internalRect] = useRect({}) + const rect = externalRect ?? internalRect + const getTransform = useTransform() + const lenis = useLenis() + const autoId = useId() + const debugId = typeof debug === 'string' ? debug : autoId + const debugRef = useRef(debug) + + const { height: windowHeight = 0 } = useWindowSize() + + const isReady = rect?.top !== undefined + + const [elementStartKeyword, viewportStartKeyword] = + typeof start === 'string' ? start.split(' ') : [start] + const [elementEndKeyword, viewportEndKeyword] = + typeof end === 'string' ? end.split(' ') : [end] + + let viewportStart = isNumber(viewportStartKeyword) + ? Number.parseFloat(viewportStartKeyword as string) + : 0 + if (viewportStartKeyword === 'top') viewportStart = 0 + if (viewportStartKeyword === 'center') viewportStart = windowHeight * 0.5 + if (viewportStartKeyword === 'bottom') viewportStart = windowHeight + + let viewportEnd = isNumber(viewportEndKeyword) + ? Number.parseFloat(viewportEndKeyword as string) + : 0 + if (viewportEndKeyword === 'top') viewportEnd = 0 + if (viewportEndKeyword === 'center') viewportEnd = windowHeight * 0.5 + if (viewportEndKeyword === 'bottom') viewportEnd = windowHeight + + let elementStart = isNumber(elementStartKeyword) + ? Number.parseFloat(elementStartKeyword as string) + : rect?.bottom || 0 + if (elementStartKeyword === 'top') elementStart = rect?.top || 0 + if (elementStartKeyword === 'center') + elementStart = (rect?.top || 0) + (rect?.height || 0) * 0.5 + if (elementStartKeyword === 'bottom') elementStart = rect?.bottom || 0 + + elementStart += offset + + let elementEnd = isNumber(elementEndKeyword) + ? Number.parseFloat(elementEndKeyword as string) + : rect?.top || 0 + if (elementEndKeyword === 'top') elementEnd = rect?.top || 0 + if (elementEndKeyword === 'center') + elementEnd = (rect?.top || 0) + (rect?.height || 0) * 0.5 + if (elementEndKeyword === 'bottom') elementEnd = rect?.bottom || 0 + + elementEnd += offset + + const startValue = elementStart - viewportStart + const endValue = elementEnd - viewportEnd + + const handleProgress = useEffectEvent( + (progress: number, lastProgress: number) => { + const direction: 1 | -1 = progress >= lastProgress ? 1 : -1 + const clampedProgress = clamp(0, progress, 1) + const isActive = progress >= 0 && progress <= 1 + + onProgress?.({ + height: endValue - startValue, + isActive, + progress: clampedProgress, + lastProgress, + direction, + steps: Array.from({ length: steps }).map((_, i) => + clamp(0, mapRange(i / steps, (i + 1) / steps, progress, 0, 1), 1) + ), + }) + + if (debugRef.current) { + const { translate } = getTransform() + scrollTriggerStore.update(debugId, { + progress: clampedProgress, + isActive, + startPx: startValue, + endPx: endValue, + rect: { + top: rect?.top || 0, + left: rect?.left || 0, + width: rect?.width || 0, + height: rect?.height || 0, + }, + translateY: translate.y, + }) + } + } + ) + + const handleEnter = useEffectEvent( + (progress: number, lastProgress: number) => { + const direction: 1 | -1 = progress >= lastProgress ? 1 : -1 + onEnter?.({ progress: clamp(0, progress, 1), direction }) + } + ) + + const handleLeave = useEffectEvent( + (progress: number, lastProgress: number) => { + const direction: 1 | -1 = progress >= lastProgress ? 1 : -1 + onLeave?.({ progress: clamp(0, progress, 1), direction }) + } + ) + + const [setProgress] = useLazyState( + Number.NaN, + (progress: number, lastProgress: number | undefined) => { + if (Number.isNaN(progress) || progress === undefined) return + if (lastProgress === undefined) return + + if ( + (progress >= 0 && lastProgress < 0) || + (progress <= 1 && lastProgress > 1) + ) { + handleEnter(progress, lastProgress) + } + + if (!(clamp(0, progress, 1) === clamp(0, lastProgress, 1))) { + handleProgress(progress, lastProgress) + } + + if ( + (progress < 0 && lastProgress >= 0) || + (progress > 1 && lastProgress <= 1) + ) { + handleLeave(progress, lastProgress) + } + }, + [endValue, startValue, steps] + ) + + const update = useEffectEvent(() => { + if (disabled) return + if (!isReady) return + + const scroll = lenis ? Math.floor(lenis.scroll) : window.scrollY + const { translate } = getTransform() + + // support for Lenis infinite scroll + const progress = mapRange( + 0, + endValue - startValue, + modulo(scroll - translate.y - startValue, lenis?.limit ?? 0), + 0, + 1 + ) + + setProgress(progress) + }) + + useEffect(() => { + if (lenis) { + lenis.on('scroll', update) + return () => { + lenis.off('scroll', update) + } + } + + // Fallback to native scroll + update() + window.addEventListener('scroll', update, false) + + return () => { + window.removeEventListener('scroll', update, false) + } + }, [lenis, update, ...deps]) + + // Recalculate when parent transforms change + useTransform(update) + + // Run update when deps change + useEffect(update, [...deps]) + + // Debug: register/unregister from store + useEffect(() => { + if (!debug) return + + scrollTriggerStore.register(debugId, { + id: debugId, + start, + end, + startPx: startValue, + endPx: endValue, + progress: 0, + isActive: false, + rect: { + top: rect?.top || 0, + left: rect?.left || 0, + width: rect?.width || 0, + height: rect?.height || 0, + }, + translateY: 0, + }) + + return () => { + scrollTriggerStore.unregister(debugId) + } + }, [debug, debugId]) + + // Debug: sync store when rect/positions change + useEffect(() => { + if (!debug) return + + scrollTriggerStore.update(debugId, { + start, + end, + startPx: startValue, + endPx: endValue, + rect: { + top: rect?.top || 0, + left: rect?.left || 0, + width: rect?.width || 0, + height: rect?.height || 0, + }, + }) + }, [ + debug, + debugId, + start, + end, + startValue, + endValue, + rect?.top, + rect?.left, + rect?.width, + rect?.height, + ]) + + return [setRectRef, rect] as const +} diff --git a/packages/hamo/src/use-scroll-trigger/store.ts b/packages/hamo/src/use-scroll-trigger/store.ts new file mode 100644 index 0000000..0b623dd --- /dev/null +++ b/packages/hamo/src/use-scroll-trigger/store.ts @@ -0,0 +1,60 @@ +// A tiny synchronous pub/sub used only by the debug overlay. Inlined (instead +// of nanoevents) to keep hamo free of runtime dependencies. + +export interface TriggerEntry { + id: string + start: string + end: string + startPx: number + endPx: number + progress: number + isActive: boolean + rect: { top: number; left: number; width: number; height: number } + translateY: number +} + +const subscribers = new Set<() => void>() +const triggers = new Map() +let snapshot: TriggerEntry[] = [] + +function updateSnapshot() { + snapshot = Array.from(triggers.values()) +} + +function emit() { + for (const callback of subscribers) callback() +} + +export const scrollTriggerStore = { + register(id: string, entry: TriggerEntry) { + triggers.set(id, entry) + updateSnapshot() + emit() + }, + + update(id: string, partial: Partial) { + const existing = triggers.get(id) + if (existing) { + Object.assign(existing, partial) + updateSnapshot() + emit() + } + }, + + unregister(id: string) { + triggers.delete(id) + updateSnapshot() + emit() + }, + + getSnapshot(): TriggerEntry[] { + return snapshot + }, + + subscribe(callback: () => void) { + subscribers.add(callback) + return () => { + subscribers.delete(callback) + } + }, +} diff --git a/packages/hamo/src/use-transform/README.md b/packages/hamo/src/use-transform/README.md new file mode 100644 index 0000000..71ae4b9 --- /dev/null +++ b/packages/hamo/src/use-transform/README.md @@ -0,0 +1,135 @@ +# useTransform + +A context-based transform accumulation system. `TransformProvider` tracks programmatic transforms (translate, rotate, scale) and propagates them down the component tree. `useTransform` lets child components read the accumulated transform or react to changes. + +The primary use case is **scroll trigger compensation** — when a parent element is translated programmatically (e.g., parallax), child `useScrollTrigger` hooks need to know about that offset to compute accurate progress values. + +## Usage + +```jsx +import { useRef } from 'react' +import { TransformProvider, useTransform } from 'hamo' +import type { TransformRef } from 'hamo' + +function ParallaxSection() { + const transformRef = useRef(null) + + // Set transforms imperatively (e.g., from a scroll callback) + function onScroll(y) { + transformRef.current?.setTranslate(0, y) + } + + return ( + + + + ) +} + +function ChildComponent() { + // Read accumulated transform from all parent providers + const getTransform = useTransform() + const { translate } = getTransform() + // translate.y reflects the parent's offset + + // Or react to changes: + useTransform((transform) => { + console.log('Parent moved to:', transform.translate.y) + }) + + return
Content
+} +``` + +## TransformProvider + +Wraps a subtree with a transform context. Nested providers accumulate transforms: +- **Translate** and **rotate**: additive +- **Scale**: multiplicative + +### Props + +- `children`: (ReactNode) Child components. +- `ref`: (Ref\) Optional imperative handle. + +### TransformRef Methods + +- `setTranslate(x?, y?, z?)` — Set translation (defaults: 0). +- `setRotate(x?, y?, z?)` — Set rotation in degrees (defaults: 0). +- `setScale(x?, y?, z?)` — Set scale (defaults: 1). + +## useTransform + +### Without callback + +Returns a `getTransform()` function to read the current accumulated transform on demand. + +```jsx +const getTransform = useTransform() +const { translate, rotate, scale } = getTransform() +``` + +### With callback + +Registers a callback that fires whenever any ancestor `TransformProvider` updates its transform. + +```jsx +useTransform((transform) => { + element.style.transform = `translateY(${transform.translate.y}px)` +}) +``` + +### Parameters + +- `callback`: (function, optional) Fired with the accumulated `Transform` on every change. +- `deps`: (array, default: `[]`) Dependencies for the callback effect. + +### Return Value + +Returns `getTransform` — a function that returns the current accumulated `Transform`. + +## Transform Type + +```ts +type Transform = { + translate: { x: number; y: number; z: number } + rotate: { x: number; y: number; z: number } + scale: { x: number; y: number; z: number } +} +``` + +## Example: Nested Providers + +```jsx +import { useRef } from 'react' +import { TransformProvider, useTransform } from 'hamo' +import type { TransformRef } from 'hamo' + +function Outer() { + const ref = useRef(null) + ref.current?.setTranslate(0, 100) // y = 100 + + return ( + + + + ) +} + +function Inner() { + const ref = useRef(null) + ref.current?.setTranslate(0, 50) // y = 50 + + return ( + + + + ) +} + +function Leaf() { + const getTransform = useTransform() + const { translate } = getTransform() + // translate.y === 150 (100 + 50, accumulated from both parents) +} +``` diff --git a/packages/hamo/src/use-transform/index.tsx b/packages/hamo/src/use-transform/index.tsx new file mode 100644 index 0000000..4bd6399 --- /dev/null +++ b/packages/hamo/src/use-transform/index.tsx @@ -0,0 +1,228 @@ +'use client' + +import { + createContext, + forwardRef, + type ReactNode, + useContext, + useEffect, + useImperativeHandle, + useRef, +} from 'react' +import { useEffectEvent } from '../use-effect-event' + +const DEFAULT_TRANSFORM = { + translate: { x: 0, y: 0, z: 0 }, + rotate: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 }, + userData: {} as Record, +} + +export type Transform = typeof DEFAULT_TRANSFORM +type TransformCallback = (transform: Transform) => void + +export type TransformRef = { + setTranslate: (x?: number, y?: number, z?: number) => void + setRotate: (x?: number, y?: number, z?: number) => void + setScale: (x?: number, y?: number, z?: number) => void + setUserData: (data: Record) => void +} + +type TransformContextType = { + getTransform: () => Transform + addCallback: (callback: TransformCallback) => void + removeCallback: (callback: TransformCallback) => void + setTranslate: (x?: number, y?: number, z?: number) => void + setRotate: (x?: number, y?: number, z?: number) => void + setScale: (x?: number, y?: number, z?: number) => void + setUserData: (data: Record) => void +} + +export const TransformContext = createContext({ + getTransform: () => structuredClone(DEFAULT_TRANSFORM), + addCallback: () => {}, + removeCallback: () => {}, + setTranslate: () => {}, + setRotate: () => {}, + setScale: () => {}, + setUserData: () => {}, +}) + +type TransformProviderProps = { + children: ReactNode +} + +/** + * Provider for managing element transforms in a composable hierarchy. + * + * Nested providers accumulate transforms — translate and rotate are additive, + * scale is multiplicative. This lets child components account for parent + * transforms (e.g., parallax offsets) when computing scroll positions. + * + * @example + * ```tsx + * import { TransformProvider, useTransform } from 'hamo' + * + * function ParallaxWrapper({ children }) { + * const ref = useRef(null) + * + * // Update transform on scroll + * useScrollTrigger({ + * onProgress: ({ progress }) => { + * ref.current?.setTranslate(0, progress * -100) + * }, + * }) + * + * return ( + * + * {children} + * + * ) + * } + * ``` + */ +export const TransformProvider = forwardRef< + TransformRef, + TransformProviderProps +>(function TransformProvider({ children }, ref) { + const parentTransformRef = useRef(structuredClone(DEFAULT_TRANSFORM)) + const transformRef = useRef(structuredClone(DEFAULT_TRANSFORM)) + + function getTransform(): Transform { + const transform = structuredClone(parentTransformRef.current) + + transform.translate.x += transformRef.current.translate.x + transform.translate.y += transformRef.current.translate.y + transform.translate.z += transformRef.current.translate.z + + transform.rotate.x += transformRef.current.rotate.x + transform.rotate.y += transformRef.current.rotate.y + transform.rotate.z += transformRef.current.rotate.z + + transform.scale.x *= transformRef.current.scale.x + transform.scale.y *= transformRef.current.scale.y + transform.scale.z *= transformRef.current.scale.z + + transform.userData = { + ...transform.userData, + ...transformRef.current.userData, + } + + return transform + } + + const callbacksRef = useRef([]) + + const addCallback = useEffectEvent((callback: TransformCallback) => { + callbacksRef.current.push(callback) + }) + + const removeCallback = useEffectEvent((callback: TransformCallback) => { + callbacksRef.current = callbacksRef.current.filter((c) => c !== callback) + }) + + const update = useEffectEvent(() => { + const transform = getTransform() + for (const callback of callbacksRef.current) { + callback(transform) + } + }) + + function setTranslate(x = 0, y = 0, z = 0) { + if (!Number.isNaN(x)) transformRef.current.translate.x = Number(x) + if (!Number.isNaN(y)) transformRef.current.translate.y = Number(y) + if (!Number.isNaN(z)) transformRef.current.translate.z = Number(z) + + update() + } + + function setRotate(x = 0, y = 0, z = 0) { + if (!Number.isNaN(x)) transformRef.current.rotate.x = Number(x) + if (!Number.isNaN(y)) transformRef.current.rotate.y = Number(y) + if (!Number.isNaN(z)) transformRef.current.rotate.z = Number(z) + update() + } + + function setScale(x = 1, y = 1, z = 1) { + if (!Number.isNaN(x)) transformRef.current.scale.x = Number(x) + if (!Number.isNaN(y)) transformRef.current.scale.y = Number(y) + if (!Number.isNaN(z)) transformRef.current.scale.z = Number(z) + update() + } + + function setUserData(data: Record) { + Object.assign(transformRef.current.userData, data) + update() + } + + // Inherit parent transforms + useTransform((transform) => { + parentTransformRef.current = structuredClone(transform) + update() + }) + + useImperativeHandle(ref, () => ({ + setTranslate, + setRotate, + setScale, + setUserData, + })) + + return ( + + {children} + + ) +}) + +/** + * Hook to access and react to transform changes from TransformProvider. + * + * Without a callback, returns a `getTransform()` function to read the current + * accumulated transform. With a callback, it fires whenever any ancestor + * TransformProvider updates its transform. + * + * @param callback - Optional callback fired on transform changes + * @param deps - Dependencies for the callback effect + * @returns Function to get current accumulated transform + * + * @example + * ```tsx + * // Read transform on demand + * const getTransform = useTransform() + * const { translate } = getTransform() + * + * // React to transform changes + * useTransform((transform) => { + * element.style.transform = `translateY(${transform.translate.y}px)` + * }) + * ``` + */ +export function useTransform( + callback?: TransformCallback, + deps = [] as unknown[] +) { + const { getTransform, addCallback, removeCallback } = + useContext(TransformContext) + + useEffect(() => { + if (!callback) return + + addCallback(callback) + return () => { + removeCallback(callback) + } + }, [callback, addCallback, removeCallback, ...deps]) + + return getTransform +} diff --git a/packages/hamo/tsdown.config.ts b/packages/hamo/tsdown.config.ts index 32543a8..ad8d002 100644 --- a/packages/hamo/tsdown.config.ts +++ b/packages/hamo/tsdown.config.ts @@ -5,11 +5,17 @@ import { defineConfig } from 'tsdown' // output. `unbundle` preserves the per-file `"use client"` directives so the pure // utilities (useObjectFit) stay usable in Server Components. export default defineConfig({ - entry: { hamo: 'src/index.ts' }, + entry: { + hamo: 'src/index.ts', + 'use-scroll-trigger/debugger': 'src/use-scroll-trigger/debugger.tsx', + }, outDir: 'dist', target: 'es2022', platform: 'neutral', format: ['esm', 'cjs'], + // lenis is an optional peer (useScrollTrigger falls back to native scroll); + // never bundle it. + external: [/^lenis(\/|$)/], unbundle: true, dts: true, sourcemap: true, diff --git a/playground/react/scroll-trigger-app.tsx b/playground/react/scroll-trigger-app.tsx new file mode 100644 index 0000000..ccf4f12 --- /dev/null +++ b/playground/react/scroll-trigger-app.tsx @@ -0,0 +1,730 @@ +import { useScrollTrigger, TransformProvider, useRect } from 'hamo' +import { Debugger } from 'hamo/scroll-trigger/debugger' +import type { TransformRef, UseScrollTriggerOptions } from 'hamo' +import { useRef, useState } from 'react' + +function Section({ + title, + children, +}: { + title: string + children: React.ReactNode +}) { + return ( +
+

{title}

+
{children}
+
+ ) +} + +function Value({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+ {label} + {value ?? '---'} +
+ ) +} + +// 1. Hero / Intro +function HeroSection() { + return ( +
+

+ Scroll-based progress tracking with GSAP ScrollTrigger-like position + syntax. Integrates with Lenis when available, falls back to native + scroll events. Scroll down to explore each feature. +

+

+ Position syntax:{' '} + "element-position viewport-position" +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PositionElementViewport
topTop edge of elementTop of viewport
centerCenter of elementCenter of viewport
bottomBottom edge of elementBottom of viewport
numberPixel offset from topPixel offset from top
+
+ ) +} + +// 2. Basic Progress +function BasicProgressDemo() { + const progressBarRef = useRef(null) + const boxRef = useRef(null) + const progressValueRef = useRef(null) + const activeValueRef = useRef(null) + const heightValueRef = useRef(null) + const directionValueRef = useRef(null) + + const [setRef] = useScrollTrigger({ + start: 'bottom bottom', + end: 'top top', + debug: 'basic', + onEnter: () => { + if (boxRef.current) boxRef.current.dataset.active = 'true' + }, + onLeave: () => { + if (boxRef.current) boxRef.current.dataset.active = 'false' + }, + onProgress: ({ progress, isActive, height, direction }) => { + if (progressBarRef.current) { + progressBarRef.current.style.transform = `scaleX(${progress})` + } + if (progressValueRef.current) { + progressValueRef.current.textContent = progress.toFixed(3) + } + if (activeValueRef.current) { + activeValueRef.current.textContent = isActive ? 'true' : 'false' + } + if (heightValueRef.current) { + heightValueRef.current.textContent = `${Math.round(height)}px` + } + if (directionValueRef.current) { + directionValueRef.current.textContent = + direction === 1 ? '\u2193 down' : '\u2191 up' + } + }, + }) + + return ( +
+

+ Tracks scroll progress from 0 to 1 as the element traverses the + viewport. Uses start: "bottom bottom" and{' '} + end: "top top" (the defaults) for full traversal. +

+
+ Scroll to see progress +
+
{ + setRef(el) + boxRef.current = el + }} + className="st-progress-box" + data-active="false" + > +
+
+ 0.000} + /> + false} + /> + ---} /> + ---} + /> +
+

start: "bottom bottom" / end: "top top"

+
+
+ Scroll back up +
+
+ ) +} + +// 3. Enter / Leave Events +function EnterLeaveDemo() { + const boxRef = useRef(null) + const logRef = useRef(null) + const eventsRef = useRef([]) + + const addEvent = ( + type: 'enter' | 'leave', + progress: number, + direction: 1 | -1 + ) => { + const time = new Date().toLocaleTimeString('en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) + const className = type === 'enter' ? 'st-event-enter' : 'st-event-leave' + const label = type === 'enter' ? 'ENTER' : 'LEAVE' + const dir = direction === 1 ? '\u2193' : '\u2191' + eventsRef.current = [ + ...eventsRef.current.slice(-4), + `[${time}] ${label} ${dir} progress: ${progress.toFixed(3)}`, + ] + if (logRef.current) { + logRef.current.innerHTML = eventsRef.current.join('
') + } + } + + const [setRef] = useScrollTrigger({ + start: 'bottom bottom', + end: 'top top', + debug: 'events', + onEnter: ({ progress, direction }) => { + if (boxRef.current) boxRef.current.dataset.active = 'true' + addEvent('enter', progress, direction) + }, + onLeave: ({ progress, direction }) => { + if (boxRef.current) boxRef.current.dataset.active = 'false' + addEvent('leave', progress, direction) + }, + }) + + return ( +
+

+ onEnter fires when the element enters the trigger zone.{' '} + onLeave fires when it exits. Each callback receives{' '} + direction (1 = down, -1 = up). Scroll past this element and + back to see the event log update. +

+
+ Scroll to trigger enter/leave +
+
{ + setRef(el) + boxRef.current = el + }} + className="st-event-box" + data-active="false" + > + +
+ Waiting for events... +
+
+
+ Scroll back up +
+
+ ) +} + +// 4. Position Combinations +function PositionCard({ + startPos, + endPos, + label, +}: { + startPos: string + endPos: string + label: string +}) { + const progressBarRef = useRef(null) + const cardRef = useRef(null) + const valueRef = useRef(null) + + const [setRef] = useScrollTrigger({ + start: startPos as UseScrollTriggerOptions['start'], + end: endPos as UseScrollTriggerOptions['end'], + debug: label, + onEnter: () => { + if (cardRef.current) cardRef.current.dataset.active = 'true' + }, + onLeave: () => { + if (cardRef.current) cardRef.current.dataset.active = 'false' + }, + onProgress: ({ progress }) => { + if (progressBarRef.current) { + progressBarRef.current.style.transform = `scaleX(${progress})` + } + if (valueRef.current) { + valueRef.current.textContent = progress.toFixed(3) + } + }, + }) + + return ( +
{ + setRef(el) + cardRef.current = el + }} + className="st-position-card" + data-active="false" + > +
+
{label}
+
+ start: "{startPos}" / end: "{endPos}" +
+
+ 0.000 +
+
+ ) +} + +function PositionCombinationsDemo() { + return ( +
+

+ Different start / end positions change when + progress runs. Compare how each configuration behaves as you scroll. +

+
+ Scroll to compare positions +
+
+ + + +
+
+ Scroll back up +
+
+ ) +} + +// 5. Steps / Staggered Animation +function StepsDemo() { + const itemRefs = useRef<(HTMLDivElement | null)[]>([]) + const STEP_COUNT = 6 + + const [setRef] = useScrollTrigger({ + start: 'bottom bottom', + end: 'top top', + steps: STEP_COUNT, + debug: 'steps', + onProgress: ({ steps }) => { + for (let i = 0; i < steps.length; i++) { + const el = itemRefs.current[i] + if (el) { + const stepProgress = steps[i] + el.style.opacity = String(0.2 + stepProgress * 0.8) + el.style.transform = `translateY(${(1 - stepProgress) * 20}px)` + el.dataset.visible = stepProgress > 0 ? 'true' : 'false' + } + } + }, + }) + + return ( +
+

+ Using steps: {STEP_COUNT} to subdivide progress into{' '} + {STEP_COUNT} discrete sub-ranges. Each item animates based on its + individual step progress, creating a staggered effect. +

+
+ Scroll to see staggered animation +
+
+ {Array.from({ length: STEP_COUNT }).map((_, i) => ( +
{ + itemRefs.current[i] = el + }} + className="st-step-item" + data-visible="false" + > + Step {i + 1} / {STEP_COUNT} +
+ ))} +
+
+ Scroll back up +
+
+ ) +} + +// 6. Offset +function OffsetDemo() { + const cardARef = useRef(null) + const cardBRef = useRef(null) + const valueARef = useRef(null) + const valueBRef = useRef(null) + + const [setRefA] = useScrollTrigger({ + start: 'bottom bottom', + end: 'top top', + offset: 0, + debug: 'offset-0', + onEnter: () => { + if (cardARef.current) cardARef.current.dataset.active = 'true' + }, + onLeave: () => { + if (cardARef.current) cardARef.current.dataset.active = 'false' + }, + onProgress: ({ progress }) => { + if (valueARef.current) { + valueARef.current.textContent = progress.toFixed(3) + } + }, + }) + + const [setRefB] = useScrollTrigger({ + start: 'bottom bottom', + end: 'top top', + offset: -200, + debug: 'offset-200', + onEnter: () => { + if (cardBRef.current) cardBRef.current.dataset.active = 'true' + }, + onLeave: () => { + if (cardBRef.current) cardBRef.current.dataset.active = 'false' + }, + onProgress: ({ progress }) => { + if (valueBRef.current) { + valueBRef.current.textContent = progress.toFixed(3) + } + }, + }) + + return ( +
+

+ The offset option shifts element positions by a pixel + amount. Compare offset: 0 (default) vs{' '} + offset: -200 - the second triggers earlier. +

+
+ Scroll to compare offsets +
+
+
{ + setRefA(el) + cardARef.current = el + }} + className="st-offset-card" + data-active="false" + > +
offset: 0
+
+ 0.000 +
+
+
{ + setRefB(el) + cardBRef.current = el + }} + className="st-offset-card" + data-active="false" + > +
offset: -200
+
+ 0.000 +
+
+
+
+ Scroll back up +
+
+ ) +} + +// 7. Disabled Toggle +function DisabledDemo() { + const [disabled, setDisabled] = useState(false) + const progressBarRef = useRef(null) + const boxRef = useRef(null) + const valueRef = useRef(null) + + const [setRef] = useScrollTrigger({ + start: 'bottom bottom', + end: 'top top', + disabled, + debug: 'disabled', + onEnter: () => { + if (boxRef.current) boxRef.current.dataset.active = 'true' + }, + onLeave: () => { + if (boxRef.current) boxRef.current.dataset.active = 'false' + }, + onProgress: ({ progress }) => { + if (progressBarRef.current) { + progressBarRef.current.style.transform = `scaleX(${progress})` + } + if (valueRef.current) { + valueRef.current.textContent = progress.toFixed(3) + } + }, + }) + + return ( +
+

+ The disabled option stops progress updates. Toggle it to + see the progress bar freeze in place. +

+
+ +
+
+ Scroll to see progress {disabled ? '(disabled)' : ''} +
+
{ + setRef(el) + boxRef.current = el + }} + className="st-progress-box" + data-active="false" + > +
+
+ 0.000} /> + +
+
+
+ Scroll back up +
+
+ ) +} + +// 8. CSS Custom Property +function CSSPropertyDemo() { + const boxRef = useRef(null) + const valueRef = useRef(null) + + const [setRef] = useScrollTrigger({ + start: 'bottom bottom', + end: 'top top', + debug: 'css-prop', + onProgress: ({ progress }) => { + if (boxRef.current) { + boxRef.current.style.setProperty('--progress', String(progress)) + } + if (valueRef.current) { + valueRef.current.textContent = progress.toFixed(3) + } + }, + }) + + return ( +
+

+ Set --progress as a CSS custom property to drive transforms + purely through CSS. The box below uses translateY,{' '} + scale, rotate, and opacity all + driven by a single variable. +

+
+ Scroll to animate via CSS variable +
+
{ + setRef(el) + boxRef.current = el + }} + className="st-css-demo" + > +
+ + --progress: 0.000 + +
+
+ Scroll back up +
+
+ ) +} + +// 9. TransformProvider (Parallax Compensation) +function TransformChildTrigger() { + const progressBarRef = useRef(null) + const valueRef = useRef(null) + const boxRef = useRef(null) + + const [setRectRef, rect] = useRect({ + ignoreTransform: true, + }) + useScrollTrigger({ + rect, + debug: 'child', + onEnter: () => { + if (boxRef.current) boxRef.current.dataset.active = 'true' + }, + onLeave: () => { + if (boxRef.current) boxRef.current.dataset.active = 'false' + }, + onProgress: ({ progress }) => { + if (progressBarRef.current) { + progressBarRef.current.style.transform = `scaleX(${progress})` + } + if (valueRef.current) { + valueRef.current.textContent = progress.toFixed(3) + } + }, + }) + + return ( +
{ + setRectRef(el) + boxRef.current = el + }} + className="st-transform-child" + data-active="false" + > +
+ 0.000} /> +

+ This child compensates for the parent's translateY +

+
+ ) +} + +function TransformDemo() { + const transformRef = useRef(null) + const parentRef = useRef(null) + const offsetValueRef = useRef(null) + + const [setRef] = useScrollTrigger({ + start: 'bottom bottom', + end: 'top top', + debug: 'parallax', + onProgress: ({ progress }) => { + const y = (progress - 0.5) * -400 + transformRef.current?.setTranslate(0, y) + if (parentRef.current) { + parentRef.current.style.transform = `translateY(${y}px)` + } + if (offsetValueRef.current) { + offsetValueRef.current.textContent = `${Math.round(y)}px` + } + }, + }) + + return ( +
+

+ When a parent element is translated programmatically (e.g., parallax), + child useScrollTrigger hooks need to know about that + offset. TransformProvider propagates transform state down + the tree so child triggers compute accurate progress despite the parent + moving. +

+
+ Scroll to see parallax compensation +
+ +
{ + setRef(el) + parentRef.current = el + }} + className="st-transform-parent" + > +
+ 0px} + /> +
+ +
+
+
+ Scroll back up +
+
+ ) +} + +export default function ScrollTriggerApp() { + return ( +
+ +
+

useScrollTrigger

+

+ Scroll-based progress tracking with position syntax, enter/leave + callbacks, steps, offset, and CSS variable integration. +

+
+ + + + + + + + + + + + +
+ ) +} diff --git a/playground/react/scroll-trigger-style.css b/playground/react/scroll-trigger-style.css new file mode 100644 index 0000000..b9231b4 --- /dev/null +++ b/playground/react/scroll-trigger-style.css @@ -0,0 +1,434 @@ +body { + min-height: 100vh; +} + +.st-playground { + max-width: 800px; + margin: 0 auto; + padding: 0 24px 100px; +} + +.st-playground header { + padding: 40px 0; + text-align: center; + border-bottom: 1px solid var(--color-border); + margin-bottom: 40px; +} + +.st-playground header h1 { + font-family: monospace; + font-size: 24px; + font-weight: 400; + text-transform: uppercase; + letter-spacing: 0.1em; + margin: 0 0 8px; +} + +.st-playground header p { + color: var(--color-muted); + margin: 0; + font-size: 14px; + line-height: 1.6; +} + +.st-section { + margin-bottom: 48px; + padding-bottom: 48px; + border-bottom: 1px solid var(--color-border); +} + +.st-section h2 { + font-family: monospace; + font-size: 14px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-accent); + margin: 0 0 12px; +} + +.st-description { + font-size: 14px; + color: var(--color-muted); + margin: 0 0 20px; + line-height: 1.6; +} + +.st-description code { + background: rgba(255, 255, 255, 0.1); + padding: 2px 6px; + border-radius: 4px; + font-size: 12px; +} + +.st-hint { + font-size: 12px; + color: var(--color-accent); + margin: 12px 0 0; + font-style: italic; + font-family: monospace; +} + +.st-section-content { + display: flex; + flex-direction: column; + gap: 16px; +} + +.st-values-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 12px; +} + +.st-value { + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--color-border); + border-radius: 8px; +} + +.st-value-label { + font-family: monospace; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-muted); +} + +.st-value-data { + font-family: monospace; + font-size: 18px; + font-weight: 500; +} + +/* Spacer between sections */ +.st-spacer { + height: 60vh; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-muted); + font-family: monospace; + font-size: 12px; +} + +/* Position syntax table */ +.st-syntax-table { + width: 100%; + border-collapse: collapse; + font-family: monospace; + font-size: 13px; + margin-top: 8px; +} + +.st-syntax-table th { + text-align: left; + padding: 8px 12px; + border-bottom: 1px solid var(--color-border); + color: var(--color-muted); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 600; +} + +.st-syntax-table td { + padding: 8px 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.st-syntax-table td:first-child { + color: var(--color-accent); +} + +/* Progress box */ +.st-progress-box { + position: relative; + padding: 40px; + background: rgba(255, 255, 255, 0.03); + border: 2px solid var(--color-border); + border-radius: 8px; + overflow: hidden; + transition: border-color 0.3s ease; +} + +.st-progress-box[data-active="true"] { + border-color: var(--color-accent); +} + +.st-progress-bar { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: var(--color-accent); + transform-origin: left; + transform: scaleX(0); +} + +.st-progress-info { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 12px; +} + +/* Enter/Leave events */ +.st-event-box { + position: relative; + padding: 40px; + background: rgba(255, 255, 255, 0.03); + border: 2px solid var(--color-border); + border-radius: 8px; + overflow: hidden; + transition: border-color 0.3s ease, background 0.3s ease; +} + +.st-event-box[data-active="true"] { + border-color: var(--color-accent); + background: rgba(227, 6, 19, 0.08); +} + +.st-event-log { + margin-top: 16px; + padding: 12px; + background: rgba(0, 0, 0, 0.3); + border-radius: 6px; + font-family: monospace; + font-size: 11px; + color: var(--color-muted); + min-height: 80px; + line-height: 1.8; +} + +.st-event-log .st-event-enter { + color: #4ade80; +} + +.st-event-log .st-event-leave { + color: #f87171; +} + +/* Position combinations */ +.st-positions-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; +} + +.st-position-card { + position: relative; + padding: 24px; + background: rgba(255, 255, 255, 0.03); + border: 2px solid var(--color-border); + border-radius: 8px; + overflow: hidden; + transition: border-color 0.3s ease; +} + +.st-position-card[data-active="true"] { + border-color: var(--color-accent); +} + +.st-position-card .st-progress-bar { + height: 2px; +} + +.st-position-label { + font-family: monospace; + font-size: 11px; + color: var(--color-muted); + margin-bottom: 8px; +} + +.st-position-value { + font-family: monospace; + font-size: 24px; + font-weight: 500; +} + +/* Steps / Staggered animation */ +.st-steps-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.st-step-item { + padding: 20px 24px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--color-border); + border-radius: 8px; + font-family: monospace; + font-size: 14px; + opacity: 0.2; + transform: translateY(20px); + transition: border-color 0.2s ease; +} + +.st-step-item[data-visible="true"] { + border-color: var(--color-accent); +} + +/* Offset comparison */ +.st-offset-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +.st-offset-card { + position: relative; + padding: 24px; + background: rgba(255, 255, 255, 0.03); + border: 2px solid var(--color-border); + border-radius: 8px; + overflow: hidden; + transition: border-color 0.3s ease; +} + +.st-offset-card[data-active="true"] { + border-color: var(--color-accent); +} + +.st-offset-label { + font-family: monospace; + font-size: 11px; + color: var(--color-muted); + margin-bottom: 8px; +} + +.st-offset-value { + font-family: monospace; + font-size: 24px; + font-weight: 500; +} + +/* Disabled toggle */ +.st-toggle-row { + display: flex; + gap: 12px; + align-items: center; +} + +.st-toggle-row button { + font-family: monospace; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 10px 16px; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-bg); + color: var(--color-fg); + cursor: pointer; + transition: all 0.15s ease; +} + +.st-toggle-row button:hover { + border-color: var(--color-accent); + color: var(--color-accent); +} + +.st-toggle-row button:active { + transform: scale(0.98); +} + +.st-toggle-row button[data-active="true"] { + background: var(--color-accent); + border-color: var(--color-accent); + color: white; +} + +/* CSS custom property demo */ +.st-css-demo { + position: relative; + height: 300px; + background: rgba(255, 255, 255, 0.03); + border: 2px solid var(--color-border); + border-radius: 8px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; +} + +.st-css-box { + width: 100px; + height: 100px; + background: var(--color-accent); + border-radius: 8px; + transform: + translateY(calc((1 - var(--progress, 0)) * 40px)) + scale(calc(0.5 + var(--progress, 0) * 0.5)) + rotate(calc(var(--progress, 0) * 180deg)); + opacity: calc(0.2 + var(--progress, 0) * 0.8); + transition: none; +} + +.st-css-label { + position: absolute; + bottom: 12px; + left: 12px; + font-family: monospace; + font-size: 11px; + color: var(--color-muted); +} + +/* TransformProvider demo */ +.st-transform-parent { + position: relative; + padding: 24px; + background: rgba(255, 255, 255, 0.03); + border: 2px dashed var(--color-border); + border-radius: 8px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.st-transform-header { + display: flex; + gap: 12px; +} + +.st-transform-child { + position: relative; + padding: 24px; + background: rgba(255, 255, 255, 0.03); + border: 2px solid var(--color-border); + border-radius: 8px; + overflow: hidden; + transition: border-color 0.3s ease; +} + +.st-transform-child[data-active="true"] { + border-color: var(--color-accent); +} + +/* Footer */ +.st-playground footer { + padding: 40px 0; + text-align: center; +} + +.st-playground footer p { + margin: 0; + font-family: monospace; + font-size: 12px; + color: var(--color-muted); +} + +.st-playground footer a { + color: var(--color-fg); + text-decoration: none; + transition: color 0.15s; +} + +.st-playground footer a:hover { + color: var(--color-accent); +} diff --git a/playground/www/layouts/Layout.astro b/playground/www/layouts/Layout.astro index 56331c0..a1b9082 100644 --- a/playground/www/layouts/Layout.astro +++ b/playground/www/layouts/Layout.astro @@ -22,6 +22,7 @@ const { title } = Astro.props React + ScrollTrigger
diff --git a/playground/www/pages/scroll-trigger.astro b/playground/www/pages/scroll-trigger.astro new file mode 100644 index 0000000..fde3dbc --- /dev/null +++ b/playground/www/pages/scroll-trigger.astro @@ -0,0 +1,10 @@ +--- +import Layout from '../layouts/Layout.astro' +import pkg from '../../../package.json' +import ScrollTriggerApp from '~/react/scroll-trigger-app' +import '~/react/scroll-trigger-style.css' +--- + + + + From af26bfb126faaaaa524299ce3feef5359e446852 Mon Sep 17 00:00:00 2001 From: arzafran Date: Mon, 8 Jun 2026 16:25:24 -0300 Subject: [PATCH 2/2] docs(playground): list scroll-trigger/transform/effect-event hooks on index Advertise the newly ported hooks in the playground's Available Hooks list (brought over from #11). --- playground/www/pages/index.astro | 3 +++ 1 file changed, 3 insertions(+) diff --git a/playground/www/pages/index.astro b/playground/www/pages/index.astro index 2e4a9ee..c862952 100644 --- a/playground/www/pages/index.astro +++ b/playground/www/pages/index.astro @@ -30,6 +30,9 @@ import pkg from '../../../packages/hamo/package.json'
  • useDebouncedCallback Debounced callbacks
  • useDebouncedEffect Debounced effects
  • useObjectFit Contain/cover calculations
  • +
  • useScrollTrigger Scroll-based progress tracking
  • +
  • useTransform Nested transform accumulation
  • +
  • useEffectEvent Stable callback reference