diff --git a/README.md b/README.md index e7099a0..3f8ab19 100644 --- a/README.md +++ b/README.md @@ -35,4 +35,5 @@ npm install @hemilabs/react-hooks | useTokenBalance | [useTokenBalance](./src/useTokenBalance) | | useTotalSupply | [useTotalSupply](./src/useTotalSupply) | | useUpdateNativeBalanceAfterReceipt | [useUpdateNativeBalanceAfterReceipt](./src/useUpdateNativeBalanceAfterReceipt) | +| useVisualViewportSize | [useVisualViewportSize](./src/useVisualViewportSize) | | useWindowSize | [useWindowSize](./src/useWindowSize) | diff --git a/src/useVisualViewportSize/README.md b/src/useVisualViewportSize/README.md new file mode 100644 index 0000000..ae62cbd --- /dev/null +++ b/src/useVisualViewportSize/README.md @@ -0,0 +1,39 @@ +# useVisualViewportSize + +Returns the current `VisualViewport` height and top offset. Updates automatically when the visual viewport is resized (e.g., when a mobile keyboard opens or the page is pinch-zoomed). + +## Import + +```ts +import { useVisualViewportSize } from "@hemilabs/react-hooks/useVisualViewportSize"; +``` + +## Parameters + +None. + +## Return Value + +| Property | Type | Description | +| --------- | -------- | ------------------------------------------------------------------------------ | +| height | `number` | Current visual viewport height in pixels | +| offsetTop | `number` | Distance from the top of the layout viewport to the top of the visual viewport | + +Both values default to `0` during server-side rendering or when `window.visualViewport` is unavailable. + +## Usage + +```tsx +import { useVisualViewportSize } from "@hemilabs/react-hooks/useVisualViewportSize"; + +function StickyFooter() { + const { height, offsetTop } = useVisualViewportSize(); + + // Keep a footer pinned above the on-screen keyboard on mobile + return ( +
+ Footer +
+ ); +} +``` diff --git a/src/useVisualViewportSize/index.ts b/src/useVisualViewportSize/index.ts new file mode 100644 index 0000000..5bff697 --- /dev/null +++ b/src/useVisualViewportSize/index.ts @@ -0,0 +1,30 @@ +import { useSyncExternalStore } from "react"; + +function subscribe(callback: (this: VisualViewport, ev: Event) => unknown) { + window.visualViewport?.addEventListener("resize", callback); + window.visualViewport?.addEventListener("scroll", callback); + return function cleanup() { + window.visualViewport?.removeEventListener("resize", callback); + window.visualViewport?.removeEventListener("scroll", callback); + }; +} + +const getHeightSnapshot = () => + typeof window !== "undefined" && window.visualViewport + ? window.visualViewport.height + : 0; + +const getOffsetTopSnapshot = () => + typeof window !== "undefined" && window.visualViewport + ? window.visualViewport.offsetTop + : 0; + +export function useVisualViewportSize() { + const height = useSyncExternalStore(subscribe, getHeightSnapshot, () => 0); + const offsetTop = useSyncExternalStore( + subscribe, + getOffsetTopSnapshot, + () => 0, + ); + return { height, offsetTop }; +}