diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 4fd14ce..4984fca 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -7,7 +7,17 @@ "WebFetch(domain:raw.githubusercontent.com)", "Bash(pnpm install:*)", "WebSearch", - "WebFetch(domain:github.com)" + "WebFetch(domain:github.com)", + "Bash(npx astro:*)", + "WebFetch(domain:gsap.com)", + "Read(//c/Program Files/GitHub CLI//**)", + "Read(//c/Program Files \\(x86\\)/**)", + "Read(//c/Users/clement/**)", + "Bash(\"/c/Program Files/GitHub CLI/gh.exe\" pr create --title \"feat: useScrollTrigger, useTransform, useEffectEvent\" --body \"$\\(cat <<'EOF'\n## Summary\n\n- **useScrollTrigger** — High-performance scroll progress tracking with GSAP ScrollTrigger-like position syntax. Zero per-frame DOM reads, zero React re-renders. Full control over scroll-driven effects for scrollytelling.\n- **useTransform / TransformProvider** — Context-based transform accumulation so child scroll triggers stay accurate when parents are translated programmatically \\(parallax\\).\n- **useEffectEvent** — Polyfill for React's experimental `useEffectEvent` \\(stable callback ref for React 17+\\).\n- **Dedicated /scroll-trigger playground** with 9 interactive demos: basic progress, direction, enter/leave events, position combinations, steps, offset, disabled toggle, CSS custom properties, and TransformProvider.\n- **Build migration** from pnpm+tsup to bun+tsdown.\n\n### useScrollTrigger features\n- Position syntax: `\"element-position viewport-position\"` \\(`top`, `center`, `bottom`, or pixel values\\)\n- Callbacks: `onEnter`, `onLeave`, `onProgress` with `progress`, `direction`, `isActive`, `height`, `steps`\n- External rect support: share a single `useRect` across multiple triggers on the same element\n- Lenis integration: automatic, with native scroll fallback\n- Infinite scroll support via `modulo`\n- TransformProvider compensation for parallax offsets\n\n## Test plan\n- [ ] Run `bun run build` — verify clean build\n- [ ] Run `bun dev` — verify playground loads at `/scroll-trigger`\n- [ ] Scroll through all 9 demo sections\n- [ ] Verify direction indicator updates in Basic Progress\n- [ ] Verify enter/leave event log shows correct direction arrows\n- [ ] Verify TransformProvider demo shows parallax compensation\n- [ ] Test with Lenis provider wrapping the app\nEOF\n\\)\" 2>&1)", + "Bash(\"/c/Program Files/GitHub:*)", + "WebFetch(domain:tympanus.net)", + "mcp__chrome-devtools__list_pages", + "mcp__chrome-devtools__navigate_page" ] } } diff --git a/bash.exe.stackdump b/bash.exe.stackdump new file mode 100644 index 0000000..8315d99 --- /dev/null +++ b/bash.exe.stackdump @@ -0,0 +1,28 @@ +Stack trace: +Frame Function Args +0007FFFFB010 00021005FE8E (000210285F68, 00021026AB6E, 000000000000, 0007FFFF9F10) msys-2.0.dll+0x1FE8E +0007FFFFB010 0002100467F9 (000000000000, 000000000000, 000000000000, 0007FFFFB2E8) msys-2.0.dll+0x67F9 +0007FFFFB010 000210046832 (000210286019, 0007FFFFAEC8, 000000000000, 000000000000) msys-2.0.dll+0x6832 +0007FFFFB010 000210068CF6 (000000000000, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x28CF6 +0007FFFFB010 000210068E24 (0007FFFFB020, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x28E24 +0007FFFFB2F0 00021006A225 (0007FFFFB020, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x2A225 +End of stack trace +Loaded modules: +000100400000 bash.exe +7FF808960000 ntdll.dll +7FF8077F0000 KERNEL32.DLL +7FF805240000 KERNELBASE.dll +7FF808730000 USER32.dll +7FF805B00000 win32u.dll +000210040000 msys-2.0.dll +7FF8085C0000 GDI32.dll +7FF805BE0000 gdi32full.dll +7FF805B30000 msvcp_win.dll +7FF8057B0000 ucrtbase.dll +7FF806F00000 advapi32.dll +7FF806700000 msvcrt.dll +7FF808510000 sechost.dll +7FF806FC0000 RPCRT4.dll +7FF804740000 CRYPTBASE.DLL +7FF805A50000 bcryptPrimitives.dll +7FF8085F0000 IMM32.DLL diff --git a/package.json b/package.json index 38b666b..cd1999a 100644 --- a/package.json +++ b/package.json @@ -1,69 +1,78 @@ -{ - "name": "hamo", - "version": "1.0.0-dev.10", - "description": "hamo means hook, do the math.", - "type": "module", - "workspaces": [ - "packages/*", - "playground", - "playground/*" - ], - "sideEffects": false, - "unpkg": "./dist/hamo.mjs", - "main": "./dist/hamo.mjs", - "module": "./dist/hamo.mjs", - "types": "./dist/hamo.d.ts", - "exports": { - ".": { - "types": "./dist/hamo.d.ts", - "default": "./dist/hamo.mjs" - }, - "./dist/*": "./dist/*" - }, - "files": [ - "dist" - ], - "author": "darkroom.engineering", - "license": "MIT", - "bugs": { - "url": "https://github.com/darkroomengineering/hamo/issues" - }, - "homepage": "https://github.com/darkroomengineering/hamo", - "repository": { - "type": "git", - "url": "https://github.com/darkroomengineering/hamo" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/darkroomengineering" - }, - "scripts": { - "build": "tsdown", - "dev": "bun run --parallel dev:build dev:playground", - "dev:build": "tsdown --watch", - "dev:playground": "bun --filter playground dev", - "version:dev": "npm version prerelease --preid dev --force --no-git-tag-version", - "version:patch": "npm version patch --force --no-git-tag-version", - "version:minor": "npm version minor --force --no-git-tag-version", - "version:major": "npm version major --force --no-git-tag-version", - "postversion": "bun run build", - "publish:main": "npm publish", - "publish:dev": "npm publish --tag dev" - }, - "keywords": [ - "react", - "hooks" - ], - "devDependencies": { - "@biomejs/biome": "1.9.4", - "tsdown": "^0.21.4", - "typescript": "^5.4.5" - }, - "dependencies": { - "nanoevents": "^9.0.0" - }, - "peerDependencies": { - "react": ">=17.0.0", - "react-dom": ">=17.0.0" - } -} +{ + "name": "hamo", + "version": "1.0.0-dev.13", + "description": "hamo means hook, do the math.", + "type": "module", + "workspaces": [ + "packages/*", + "playground", + "playground/*" + ], + "sideEffects": false, + "unpkg": "./dist/hamo.mjs", + "main": "./dist/hamo.mjs", + "module": "./dist/hamo.mjs", + "types": "./dist/hamo.d.ts", + "exports": { + ".": { + "types": "./dist/hamo.d.ts", + "default": "./dist/hamo.mjs" + }, + "./scroll-trigger": { + "types": "./dist/scroll-trigger.d.ts", + "default": "./dist/scroll-trigger.mjs" + }, + "./scroll-trigger/debugger": { + "types": "./dist/scroll-trigger/debugger.d.ts", + "default": "./dist/scroll-trigger/debugger.mjs" + }, + "./dist/*": "./dist/*" + }, + "files": [ + "dist" + ], + "author": "darkroom.engineering", + "license": "MIT", + "bugs": { + "url": "https://github.com/darkroomengineering/hamo/issues" + }, + "homepage": "https://github.com/darkroomengineering/hamo", + "repository": { + "type": "git", + "url": "https://github.com/darkroomengineering/hamo" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/darkroomengineering" + }, + "scripts": { + "build": "tsdown", + "dev": "bun run --parallel dev:build dev:playground", + "dev:build": "tsdown --watch", + "dev:playground": "bun --filter playground dev", + "version:dev": "npm version prerelease --preid dev --force --no-git-tag-version", + "version:patch": "npm version patch --force --no-git-tag-version", + "version:minor": "npm version minor --force --no-git-tag-version", + "version:major": "npm version major --force --no-git-tag-version", + "postversion": "bun run build", + "publish:main": "npm publish", + "publish:dev": "npm publish --tag dev" + }, + "keywords": [ + "react", + "hooks" + ], + "devDependencies": { + "@biomejs/biome": "1.9.4", + "tsdown": "^0.21.4", + "typescript": "^5.4.5" + }, + "dependencies": { + "nanoevents": "^9.0.0" + }, + "peerDependencies": { + "lenis": "^1.3.19", + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } +} diff --git a/packages/react/index.ts b/packages/react/index.ts index 48afd8a..120866f 100644 --- a/packages/react/index.ts +++ b/packages/react/index.ts @@ -9,6 +9,11 @@ export { useDebouncedCallback, useTimeout, } from './src/use-debounce' +export { useEffectEvent } from './src/use-effect-event' export { useObjectFit } from './src/use-object-fit' export { useIntersectionObserver } from './src/use-intersection-observer' +export { useScrollTrigger } from './src/use-scroll-trigger' +export { useTransform, TransformProvider, TransformContext } from './src/use-transform' export type { Rect } from './src/use-rect' +export type { UseScrollTriggerOptions } from './src/use-scroll-trigger' +export type { Transform, TransformRef } from './src/use-transform' diff --git a/packages/react/scroll-trigger/debugger.ts b/packages/react/scroll-trigger/debugger.ts new file mode 100644 index 0000000..41a28b8 --- /dev/null +++ b/packages/react/scroll-trigger/debugger.ts @@ -0,0 +1 @@ +export { Debugger } from '../src/use-scroll-trigger/debugger' diff --git a/packages/react/scroll-trigger/index.ts b/packages/react/scroll-trigger/index.ts new file mode 100644 index 0000000..4ecf0e1 --- /dev/null +++ b/packages/react/scroll-trigger/index.ts @@ -0,0 +1 @@ +export { useScrollTrigger } from '../src/use-scroll-trigger' diff --git a/packages/react/src/use-debounce/index.ts b/packages/react/src/use-debounce/index.ts index d60730e..a4d2f90 100644 --- a/packages/react/src/use-debounce/index.ts +++ b/packages/react/src/use-debounce/index.ts @@ -5,6 +5,7 @@ import { useRef, useState, } from 'react' +import { useEffectEvent } from '../use-effect-event' export type DebouncedFunction void> = (( ...args: Parameters @@ -42,19 +43,6 @@ function timeout(callback: (...args: any[]) => void, delay: number) { return () => clearTimeout(timeout) } -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 -} - export function useDebouncedEffect( _callback: () => void, delay: number, diff --git a/packages/react/src/use-effect-event/README.md b/packages/react/src/use-effect-event/README.md new file mode 100644 index 0000000..6d14fac --- /dev/null +++ b/packages/react/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/react/src/use-effect-event/index.ts b/packages/react/src/use-effect-event/index.ts new file mode 100644 index 0000000..30f8654 --- /dev/null +++ b/packages/react/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/react/src/use-scroll-trigger/README.md b/packages/react/src/use-scroll-trigger/README.md new file mode 100644 index 0000000..0f27a85 --- /dev/null +++ b/packages/react/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/react/src/use-scroll-trigger/debugger.tsx b/packages/react/src/use-scroll-trigger/debugger.tsx new file mode 100644 index 0000000..7e1e90f --- /dev/null +++ b/packages/react/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/react/src/use-scroll-trigger/index.ts b/packages/react/src/use-scroll-trigger/index.ts new file mode 100644 index 0000000..407d249 --- /dev/null +++ b/packages/react/src/use-scroll-trigger/index.ts @@ -0,0 +1,347 @@ +'use client' + +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 { useLenis } from 'lenis/react' +import { useEffect, useId, useRef } from 'react' +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/react/src/use-scroll-trigger/store.ts b/packages/react/src/use-scroll-trigger/store.ts new file mode 100644 index 0000000..fc6ae39 --- /dev/null +++ b/packages/react/src/use-scroll-trigger/store.ts @@ -0,0 +1,56 @@ +import { createNanoEvents } from 'nanoevents' + +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 +} + +interface StoreEvents { + change: () => void +} + +const emitter = createNanoEvents() +const triggers = new Map() +let snapshot: TriggerEntry[] = [] + +function updateSnapshot() { + snapshot = Array.from(triggers.values()) +} + +export const scrollTriggerStore = { + register(id: string, entry: TriggerEntry) { + triggers.set(id, entry) + updateSnapshot() + emitter.emit('change') + }, + + update(id: string, partial: Partial) { + const existing = triggers.get(id) + if (existing) { + Object.assign(existing, partial) + updateSnapshot() + emitter.emit('change') + } + }, + + unregister(id: string) { + triggers.delete(id) + updateSnapshot() + emitter.emit('change') + }, + + getSnapshot(): TriggerEntry[] { + return snapshot + }, + + subscribe(callback: () => void) { + return emitter.on('change', callback) + }, +} diff --git a/packages/react/src/use-transform/README.md b/packages/react/src/use-transform/README.md new file mode 100644 index 0000000..71ae4b9 --- /dev/null +++ b/packages/react/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/react/src/use-transform/index.tsx b/packages/react/src/use-transform/index.tsx new file mode 100644 index 0000000..72f6332 --- /dev/null +++ b/packages/react/src/use-transform/index.tsx @@ -0,0 +1,223 @@ +'use client' + +import { useEffectEvent } from '../use-effect-event' +import { + createContext, + forwardRef, + type ReactNode, + type Ref, + useContext, + useEffect, + useImperativeHandle, + useRef, +} from 'react' + +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(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/playground/react/scroll-trigger-app.tsx b/playground/react/scroll-trigger-app.tsx new file mode 100644 index 0000000..598a2c6 --- /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 } 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 `${string} ${string}`, + end: endPos as `${string} ${string}`, + 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/index.astro b/playground/www/pages/index.astro index 467ba5b..2d68862 100644 --- a/playground/www/pages/index.astro +++ b/playground/www/pages/index.astro @@ -13,6 +13,7 @@ import pkg from '../../../package.json' @@ -30,6 +31,8 @@ import pkg from '../../../package.json'
  • useDebouncedCallback Debounced callbacks
  • useDebouncedEffect Debounced effects
  • useObjectFit Contain/cover calculations
  • +
  • useScrollTrigger Scroll-based progress tracking
  • +
  • useEffectEvent Stable callback reference
  • 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' +--- + + + + diff --git a/tsdown.config.ts b/tsdown.config.ts index 7262997..0f82035 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -13,10 +13,14 @@ export default defineConfig([ // React ESM { ...shared, - entry: { hamo: 'packages/react/index.ts' }, + entry: { + hamo: 'packages/react/index.ts', + 'scroll-trigger': 'packages/react/scroll-trigger/index.ts', + 'scroll-trigger/debugger': 'packages/react/scroll-trigger/debugger.ts', + }, dts: true, clean: true, banner: '"use client";', - deps: { neverBundle: ['react', 'hamo'] }, + deps: { neverBundle: ['react', 'lenis', 'hamo'] }, }, ])