{"_id":"@afilmory/viewer-motion","_rev":"4-c66e26856859613e20566925b863ae4f","name":"@afilmory/viewer-motion","dist-tags":{"latest":"0.0.5"},"versions":{"0.0.2":{"name":"@afilmory/viewer-motion","version":"0.0.2","license":"MIT","_id":"@afilmory/viewer-motion@0.0.2","maintainers":[{"name":"chralpha","email":"npm@chralpha.com"},{"name":"innei","email":"tukon479@gmail.com"}],"dist":{"shasum":"92cc4e87a3c84aee86f493910f6a2415ba7b6329","tarball":"https://registry.npmjs.org/@afilmory/viewer-motion/-/viewer-motion-0.0.2.tgz","fileCount":24,"integrity":"sha512-NSjJMQu206wKUwLNM8n6yUu/FHZad9m0sOZEJ6xWG5VD17x5fXKhNhb/iE4FXLr4BkEKiFDoyZD3YBVmPEzlfA==","signatures":[{"sig":"MEUCIQDM37QcpmirfCsX3gWMmiXolURa3zNm8SD2Lot2rOjccQIgELZ55SZma5Bv0hP6KxOetQGyyQQh8JsILvzKKele00Y=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":61717},"main":"./dist/index.js","type":"module","types":"./dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"},"./*":{"types":"./dist/*.d.ts","import":"./dist/*.js"}},"gitHead":"0e10982d0e531c2b36fc58905d6fa089d8e54078","scripts":{"test":"tsx --test src/*.test.ts","build":"tsc -p tsconfig.build.json","type-check":"tsc --noEmit"},"_npmUser":{"name":"innei","email":"tukon479@gmail.com"},"_npmVersion":"11.7.0","description":"Reusable motion primitives for fullscreen media viewers","directories":{},"_nodeVersion":"22.22.0","dependencies":{"motion":"^12.34.0","@use-gesture/react":"10.3.1"},"_hasShrinkwrap":false,"devDependencies":{"react":"^19.2.4","react-dom":"^19.2.4","@types/react":"catalog:","@types/react-dom":"catalog:"},"peerDependencies":{"react":"^19.1.1","react-dom":"^19.1.1"},"_npmOperationalInternal":{"tmp":"tmp/viewer-motion_0.0.2_1777137067224_0.013728362204981748","host":"s3://npm-registry-packages-npm-production"}},"0.0.3":{"name":"@afilmory/viewer-motion","version":"0.0.3","license":"MIT","_id":"@afilmory/viewer-motion@0.0.3","maintainers":[{"name":"chralpha","email":"npm@chralpha.com"},{"name":"innei","email":"tukon479@gmail.com"}],"dist":{"shasum":"4c571c4242771b7c0e4e262ca56abc4494d2f66d","tarball":"https://registry.npmjs.org/@afilmory/viewer-motion/-/viewer-motion-0.0.3.tgz","fileCount":20,"integrity":"sha512-i7tm9fAIuBo16o4/hGcB1yHJdRnHxs1PBRlgiMPj9EneqpklQJGQ+2Eqw6qf14wRyBza2ZSSrh9+5bEpE0sC6A==","signatures":[{"sig":"MEYCIQCzsc+XKm5JZN7KNL+6FDrCTxF/ZFLp5N5cDcpoQM2h4QIhAJOKfPEfkhKpkYIMz+fVOdQCks1eWURlxWIM3LDSdoDe","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":59933},"main":"./dist/index.mjs","type":"module","types":"./dist/index.d.mts","exports":{".":{"types":"./dist/index.d.mts","import":"./dist/index.mjs"},"./*":{"types":"./dist/*.d.mts","import":"./dist/*.mjs"}},"gitHead":"33de8fa2e903b7a51e0ae0115fe9e8824d51bebd","scripts":{"test":"tsx --test src/*.test.ts","build":"tsdown","type-check":"tsc --noEmit"},"_npmUser":{"name":"innei","email":"tukon479@gmail.com"},"_npmVersion":"11.7.0","description":"Reusable motion primitives for fullscreen media viewers","directories":{},"_nodeVersion":"22.22.0","dependencies":{"motion":"^12.34.0","@use-gesture/react":"^10.3.1"},"_hasShrinkwrap":false,"devDependencies":{"react":"^19.2.4","tsdown":"0.18.0","react-dom":"^19.2.4","@types/react":"catalog:","@types/react-dom":"catalog:"},"peerDependencies":{"react":"^19.1.1","react-dom":"^19.1.1"},"_npmOperationalInternal":{"tmp":"tmp/viewer-motion_0.0.3_1777137597754_0.6705418243021481","host":"s3://npm-registry-packages-npm-production"}},"0.0.4":{"name":"@afilmory/viewer-motion","version":"0.0.4","license":"MIT","_id":"@afilmory/viewer-motion@0.0.4","maintainers":[{"name":"chralpha","email":"npm@chralpha.com"},{"name":"innei","email":"tukon479@gmail.com"}],"dist":{"shasum":"1fb4c15c40222be04f5694bdc7fa13ce2d67321f","tarball":"https://registry.npmjs.org/@afilmory/viewer-motion/-/viewer-motion-0.0.4.tgz","fileCount":23,"integrity":"sha512-0mc30NmJuMdJzAxj4uxSbZK2k88lDpoahsy6vITO2xQ3CHia3LKLXkF+Bzi0YP3GiYKwg0HiPEmQopctjFxByA==","signatures":[{"sig":"MEYCIQCqqUkKxZyrcfy/JxQF+j4GEUCHsqGK3xwVfctYwBjWkAIhAJrhtYuW2lHKW2ILQbapK0YZDO/XLEKOCOe6YNLWILRk","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":61609},"main":"./dist/index.mjs","type":"module","types":"./dist/index.d.mts","exports":{".":{"types":"./dist/index.d.mts","import":"./dist/index.mjs"},"./*":{"types":"./dist/*.d.mts","import":"./dist/*.mjs"}},"gitHead":"dc584c20f51979618d3d827c04b38286355c5bf0","scripts":{"test":"tsx --test src/*.test.ts","build":"tsdown","type-check":"tsc --noEmit"},"_npmUser":{"name":"innei","email":"tukon479@gmail.com"},"_npmVersion":"11.7.0","description":"Reusable motion primitives for fullscreen media viewers","directories":{},"_nodeVersion":"22.22.0","dependencies":{"motion":"^12.34.0","@use-gesture/react":"^10.3.1"},"_hasShrinkwrap":false,"devDependencies":{"react":"^19.2.4","tsdown":"0.18.0","react-dom":"^19.2.4","@types/react":"catalog:","@types/react-dom":"catalog:"},"peerDependencies":{"react":"^19.1.1","react-dom":"^19.1.1"},"_npmOperationalInternal":{"tmp":"tmp/viewer-motion_0.0.4_1777137860464_0.9076042753396922","host":"s3://npm-registry-packages-npm-production"}},"0.0.5":{"name":"@afilmory/viewer-motion","version":"0.0.5","description":"Reusable motion primitives for fullscreen media viewers","license":"MIT","type":"module","exports":{".":{"types":"./dist/index.d.mts","import":"./dist/index.mjs"},"./*":{"types":"./dist/*.d.mts","import":"./dist/*.mjs"}},"main":"./dist/index.mjs","types":"./dist/index.d.mts","scripts":{"build":"tsdown","test":"tsx --test src/*.test.ts","type-check":"tsc --noEmit"},"dependencies":{"@use-gesture/react":"^10.3.1","motion":"^12.34.0"},"devDependencies":{"@types/react":"catalog:","@types/react-dom":"catalog:","react":"^19.2.4","react-dom":"^19.2.4","tsdown":"0.18.0"},"peerDependencies":{"react":"^19.1.1","react-dom":"^19.1.1"},"gitHead":"72c807a197ca4b2e9ab9550e4b18d1ba1b80cde0","_id":"@afilmory/viewer-motion@0.0.5","_nodeVersion":"22.22.0","_npmVersion":"11.7.0","dist":{"integrity":"sha512-lrzQEzi7nor2xWY4SUKC99dDjC+YlTUYlCQJ0niDLGDIgxPceyYYRIXWfo/V7eoS9+PZCXBWLbCFdxY8On+4Lg==","shasum":"6b4f057ad7cc320bfc42a30319efdba9156d10af","tarball":"https://registry.npmjs.org/@afilmory/viewer-motion/-/viewer-motion-0.0.5.tgz","fileCount":23,"unpackedSize":61870,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQD7CMXaX62V4zsCC3wfZUwHV+kB+BODnI5lOMkOBnBQrQIhAKrPugFW9JQoYzSfo5Ycfpu5iPg7XvwEyII3B673KuCP"}]},"_npmUser":{"name":"innei","email":"tukon479@gmail.com"},"directories":{},"maintainers":[{"name":"chralpha","email":"npm@chralpha.com"},{"name":"innei","email":"tukon479@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/viewer-motion_0.0.5_1777138145284_0.18702535612038962"},"_hasShrinkwrap":false}},"time":{"created":"2026-04-25T17:11:07.134Z","modified":"2026-04-25T17:29:05.540Z","0.0.2":"2026-04-25T17:11:07.355Z","0.0.3":"2026-04-25T17:19:57.942Z","0.0.4":"2026-04-25T17:24:20.589Z","0.0.5":"2026-04-25T17:29:05.420Z"},"license":"MIT","description":"Reusable motion primitives for fullscreen media viewers","maintainers":[{"name":"chralpha","email":"npm@chralpha.com"},{"name":"innei","email":"tukon479@gmail.com"}],"readme":"# @afilmory/viewer-motion\n\nReusable motion primitives for fullscreen media viewers in React.\n\nThis package gives you the animation and gesture building blocks behind a modern image viewer, without owning your data model, routing, media loader, or UI chrome.\n\nIt is a good fit when you already have:\n\n- a grid or list of thumbnails\n- a fullscreen viewer or lightbox\n- React state that decides which item is open\n\nAnd you want to add:\n\n- shared-element entry and exit transitions between the trigger thumbnail and the viewer stage\n- mobile drag-to-dismiss projection\n- mobile inspector-sheet gestures\n- geometry helpers for mapping the viewer back into a thumbnail frame\n\n## What This Package Does\n\n- Finds the trigger element for the active item by `item.id`\n- Computes the fullscreen media frame inside your viewer stage\n- Produces entry and exit transition state for a temporary preview overlay\n- Projects mobile dismiss gestures back into a closing shared-element frame\n- Exposes motion values for mobile viewer interactions\n\n## What Stays in Your App\n\nThis package intentionally does not own:\n\n- routing and history timing\n- the open/close state of your viewer\n- image loading, preloading, or progressive rendering\n- toolbars, share buttons, side panels, metadata panels, thumbnail strips\n- placeholder rendering details\n- backdrop visuals\n\nThat split is deliberate. The host app owns product decisions. `@afilmory/viewer-motion` only handles motion and geometry.\n\n## Getting the Package Into Another App\n\nInside Afilmory this package is consumed as a workspace package.\n\nIf you want to use it in another React codebase, the practical options are:\n\n1. Add `packages/viewer-motion` to your own monorepo/workspace.\n2. Vendor it into your codebase and keep the public API intact.\n3. Publish it under your own registry scope and consume it like a normal package.\n\nIf you choose option 3, build the distributable files first:\n\n```bash\npnpm --filter @afilmory/viewer-motion build\n```\n\nThe package expects:\n\n- `react`\n- `react-dom`\n\nAnd it depends on:\n\n- `motion`\n- `@use-gesture/react`\n\n## Mental Model\n\nThere are 3 moving pieces:\n\n1. A trigger element in your list or grid.\n2. A viewer stage that knows where the fullscreen media should end up.\n3. A temporary preview overlay that animates between the two.\n\nThe usual lifecycle looks like this:\n\n1. User clicks a thumbnail.\n2. Your app stores both the selected item and the clicked `HTMLElement`.\n3. `useViewerTransitions()` creates an entry transition from the trigger rect to the viewer rect.\n4. `SharedElementTransitionPreview` renders the moving overlay.\n5. Your real viewer content fades or hands off underneath it.\n6. When closing, the same hook finds the live trigger again and animates back.\n\nIf the hook cannot find a valid trigger element, it degrades gracefully:\n\n- no entry animation: the viewer content becomes visible immediately\n- no exit animation: `onExitComplete` is called immediately\n\n## Trigger Contract\n\nBy default the hook looks up triggers with:\n\n```html\ndata-viewer-transition-id=\"<item.id>\"\n```\n\nUse `getViewerTransitionTriggerProps(item.id)` on the clickable thumbnail shell:\n\n```tsx\nimport { getViewerTransitionTriggerProps } from '@afilmory/viewer-motion'\n\n<button\n  type=\"button\"\n  {...getViewerTransitionTriggerProps(item.id)}\n  onClick={(event) => openViewer(item, event.currentTarget)}\n>\n  <img src={item.previewSrc} alt={item.title} />\n</button>\n```\n\nYou can replace the attribute name with `triggerAttribute` if your app already has a different contract.\n\n## Quick Start\n\nThe snippet below shows the minimal wiring for a generic React lightbox.\n\n```tsx\nimport { useState } from 'react'\nimport {\n  getViewerTransitionTriggerProps,\n  SharedElementTransitionPreview,\n  useViewerTransitions,\n} from '@afilmory/viewer-motion'\n\ntype MediaItem = {\n  id: string\n  title: string\n  width: number\n  height: number\n  previewSrc: string\n  fullSrc: string\n}\n\nconst VIEWER_LAYOUT = {\n  desktopSidebarWidthRem: 18,\n  desktopThumbnailStripHeight: 72,\n  mobileThumbnailStripHeight: 56,\n} as const\n\nexport function Gallery({ items }: { items: MediaItem[] }) {\n  const [isOpen, setIsOpen] = useState(false)\n  const [currentItem, setCurrentItem] = useState<MediaItem | undefined>()\n  const [triggerElement, setTriggerElement] = useState<HTMLElement | null>(null)\n\n  const isMobile = useIsMobile() // Implement this in your app.\n\n  const {\n    containerRef,\n    entryTransition,\n    exitTransition,\n    hasTransitionTrigger,\n    isViewerContentVisible,\n    shouldRenderBackdrop,\n    handleEntryTransitionReady,\n    handleEntryTransitionComplete,\n    handleExitAnimationComplete,\n  } = useViewerTransitions({\n    currentItem,\n    isMobile,\n    isOpen,\n    layout: VIEWER_LAYOUT,\n    onExitComplete: () => {\n      setCurrentItem(undefined)\n      setTriggerElement(null)\n    },\n    triggerElement,\n  })\n\n  const openViewer = (item: MediaItem, element: HTMLElement) => {\n    setCurrentItem(item)\n    setTriggerElement(element)\n    setIsOpen(true)\n  }\n\n  const closeViewer = () => {\n    setIsOpen(false)\n  }\n\n  const shouldMountStage = isOpen && (isViewerContentVisible || !hasTransitionTrigger)\n\n  return (\n    <>\n      <div className=\"grid\">\n        {items.map((item) => (\n          <button\n            key={item.id}\n            type=\"button\"\n            {...getViewerTransitionTriggerProps(item.id)}\n            onClick={(event) => openViewer(item, event.currentTarget)}\n          >\n            <img src={item.previewSrc} alt={item.title} />\n          </button>\n        ))}\n      </div>\n\n      {shouldRenderBackdrop && <div className=\"viewer-backdrop\" />}\n\n      {isOpen && currentItem && (\n        <div\n          ref={containerRef}\n          className=\"viewer-shell\"\n          style={{\n            pointerEvents: isViewerContentVisible ? 'auto' : 'none',\n          }}\n        >\n          <button type=\"button\" onClick={closeViewer}>\n            Close\n          </button>\n\n          {shouldMountStage ? (\n            <div\n              className=\"viewer-stage\"\n              style={{\n                opacity: isViewerContentVisible ? 1 : 0,\n                transition: 'opacity 150ms ease',\n              }}\n            >\n              <img\n                src={currentItem.fullSrc}\n                alt={currentItem.title}\n                style={{\n                  width: '100%',\n                  height: '100%',\n                  objectFit: 'contain',\n                }}\n              />\n            </div>\n          ) : null}\n        </div>\n      )}\n\n      {entryTransition && (\n        <SharedElementTransitionPreview\n          transition={entryTransition}\n          onReady={handleEntryTransitionReady}\n          onComplete={handleEntryTransitionComplete}\n        />\n      )}\n\n      {exitTransition && (\n        <SharedElementTransitionPreview\n          transition={exitTransition}\n          onComplete={handleExitAnimationComplete}\n        />\n      )}\n    </>\n  )\n}\n```\n\n### Why `handleEntryTransitionReady` Exists\n\nThe preview overlay should not always wait for the entire geometry animation to finish before your real viewer content appears.\n\n`handleEntryTransitionReady()` gives you a handoff point:\n\n- call it from `SharedElementTransitionPreview.onReady`\n- use `isViewerContentVisible` to fade or reveal the real viewer content underneath\n- keep `handleEntryTransitionComplete()` connected so the temporary overlay unmounts when the transition finishes\n\nThis makes entry animations feel faster and avoids a dead pause between click and viewer response.\n\n## Viewer Layout and Geometry\n\nBy default, frame calculations assume the fullscreen media can use the entire viewport.\n\nThat is the generic default:\n\n```ts\nimport { DEFAULT_VIEWER_FRAME_LAYOUT } from '@afilmory/viewer-motion'\n\n// {\n//   desktopSidebarWidthRem: 0,\n//   desktopThumbnailStripHeight: 0,\n//   mobileThumbnailStripHeight: 0,\n// }\n```\n\nIf your viewer reserves space for chrome, pass a `layout` object to `useViewerTransitions()` and `projectDismissedViewerMediaFrame()`:\n\n```ts\nconst layout = {\n  desktopSidebarWidthRem: 20,\n  desktopThumbnailStripHeight: 64,\n  mobileThumbnailStripHeight: 48,\n}\n```\n\nThat keeps:\n\n- entry transition target frames\n- exit transition source frames\n- drag-to-dismiss projection\n\nall aligned with the actual stage where your media is rendered.\n\n## Mobile Interactions\n\n`useViewerMobileInteractions()` gives you the gesture state for a mobile-first viewer shell.\n\nIt covers two behaviors:\n\n- drag down to dismiss\n- drag up to reveal an inspector sheet\n\nExample:\n\n```tsx\nimport {\n  DEFAULT_MOBILE_VIEWER_MEDIA_TRANSFORM_ORIGIN,\n  projectDismissedViewerMediaFrame,\n  useViewerMobileInteractions,\n} from '@afilmory/viewer-motion'\n\nconst { bindStage, dismissX, viewerLiftY, viewerScale, viewerRotate, viewerBorderRadius } =\n  useViewerMobileInteractions({\n    enabled: isMobile && isOpen,\n    isImageZoomed,\n    onDismiss: (snapshot) => {\n      const projectedFrame = projectDismissedViewerMediaFrame({\n        item: currentItem,\n        isMobile: true,\n        layout,\n        snapshot,\n        viewportRect: containerRef.current?.getBoundingClientRect() ?? null,\n      })\n\n      setExitOverrideFrame(projectedFrame)\n      setIsOpen(false)\n    },\n  })\n\n<motion.div\n  {...bindStage()}\n  style={{\n    x: dismissX,\n    y: viewerLiftY,\n    scale: viewerScale,\n    rotate: viewerRotate,\n    borderRadius: viewerBorderRadius,\n    transformOrigin: DEFAULT_MOBILE_VIEWER_MEDIA_TRANSFORM_ORIGIN,\n  }}\n/>\n```\n\n### Important\n\nThe mobile dismiss projection assumes the same transform origin is used by both:\n\n- the viewer shell you animate during the gesture\n- the frame projection utilities\n\nUse `DEFAULT_MOBILE_VIEWER_MEDIA_TRANSFORM_ORIGIN` instead of hardcoding this in multiple places.\n\n## Placeholder Rendering\n\n`SharedElementTransitionPreview` can render a placeholder underneath the transition image.\n\nThat is useful when:\n\n- you have a BlurHash or ThumbHash\n- your high-resolution viewer content mounts slightly later\n- you want to avoid a flash when the preview source changes\n\n```tsx\n<SharedElementTransitionPreview\n  transition={entryTransition}\n  onReady={handleEntryTransitionReady}\n  onComplete={handleEntryTransitionComplete}\n  renderPlaceholder={(thumbHash) => (\n    <MyHashPlaceholder thumbHash={thumbHash} />\n  )}\n/>\n```\n\n## API Overview\n\n### `getViewerTransitionTriggerProps(itemId)`\n\nReturns the default data attribute used by trigger lookup.\n\nUse it on the clickable list/grid item that opens the viewer.\n\n### `useViewerTransitions(options)`\n\nThe central hook for entry and exit shared-element transitions.\n\nKey inputs:\n\n- `currentItem`: the active media item\n- `isOpen`: whether the viewer is currently open\n- `isMobile`: your app's mobile breakpoint decision\n- `triggerElement`: the element used to open the viewer\n- `layout`: optional viewport chrome offsets\n- `currentDisplaySrc`: optional source currently shown by the viewer\n- `exitOverrideFrame`: optional projected frame, usually from drag-to-dismiss\n\nKey outputs:\n\n- `containerRef`: attach this to the viewer shell\n- `entryTransition` / `exitTransition`: pass into `SharedElementTransitionPreview`\n- `hasTransitionTrigger`: whether the current item has a live transition source, either from the clicked element or trigger recovery\n- `isViewerContentVisible`: handoff signal for your real content\n- `shouldRenderBackdrop`: useful for backdrop and placeholder layers\n- `handleEntryTransitionReady`\n- `handleEntryTransitionComplete`\n- `handleExitAnimationComplete`\n\n### `SharedElementTransitionPreview`\n\nThe temporary overlay that animates between the trigger frame and the fullscreen frame.\n\nRender it only while `entryTransition` or `exitTransition` is non-null.\n\n### `useViewerMobileInteractions(options)`\n\nProvides motion values and gesture binders for:\n\n- inspector reveal\n- dismiss drag\n- viewer shell presentation\n- chrome and backdrop presentation\n\n### Geometry Helpers\n\n- `computeViewerMediaFrame()`\n- `projectViewerMediaFrame()`\n- `projectDismissedViewerMediaFrame()`\n\nUse these when your viewer shell itself is animated and you need to map that presentation back into a shared-element exit frame.\n\n## Common Integration Mistakes\n\n### 1. Not storing the clicked `HTMLElement`\n\nThe hook can recover the live trigger later, but the best opening animation comes from passing the actual clicked element on open.\n\n### 2. Using a different `item.id` between list and viewer\n\nExit lookup depends on a stable identifier. If your route slug differs from the list id, pass the same transition id through both.\n\n### 3. Forgetting to pass `layout`\n\nIf your viewer does not use the full viewport, the transition target frame will feel \"off\" unless the hook knows about your reserved chrome.\n\n### 4. Rendering the real viewer content too early\n\nUse `hasTransitionTrigger` together with `isViewerContentVisible` to avoid mounting the heavyweight viewer stage underneath an active shared-element entry.\n\nIf you skip this and only check whether `triggerElement` is non-null, history-driven opens can recover a trigger internally while your real viewer is already visible, which produces a duplicate fullscreen image during the entry handoff.\n\n### 5. Closing before the list/grid thumbnail is live again\n\nExit animations need a live trigger element. If your list route unmounts first, the hook will skip the exit animation and complete immediately.\n\n## SSR and Client Rendering\n\nThe hooks are DOM-dependent. Use them only in client-rendered React components.\n\nOn the server they intentionally do nothing useful, because the package needs real element bounds and viewport dimensions.\n\n## Development Notes\n\nInside Afilmory this package is consumed directly from source within the monorepo.\n\nThe public API is intentionally generic enough to vendor or publish for other React apps, but the package still expects the host app to make the product-level decisions around media loading, routing, and viewer UI composition.\n","readmeFilename":"README.md"}