From f50b6b0e8f814571ed7045c5b49f909056f4a039 Mon Sep 17 00:00:00 2001 From: Gonzalo D'elia Date: Thu, 14 May 2026 17:01:28 -0300 Subject: [PATCH 1/3] Add useVisualViewportSize hook --- README.md | 1 + src/useVisualViewportSize/README.md | 39 +++++++++++++++++++++++++++++ src/useVisualViewportSize/index.ts | 22 ++++++++++++++++ 3 files changed, 62 insertions(+) create mode 100644 src/useVisualViewportSize/README.md create mode 100644 src/useVisualViewportSize/index.ts 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..0d246db --- /dev/null +++ b/src/useVisualViewportSize/index.ts @@ -0,0 +1,22 @@ +import { useSyncExternalStore } from "react"; + +function subscribe(callback: (this: VisualViewport, ev: Event) => unknown) { + window.visualViewport?.addEventListener("resize", callback); + return () => window.visualViewport?.removeEventListener("resize", 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); + const offsetTop = useSyncExternalStore(subscribe, getOffsetTopSnapshot); + return { height, offsetTop }; +} From 11e10157fc17ae615aa17ebcb2aefabf77ba8ace Mon Sep 17 00:00:00 2001 From: Gonzalo D'elia Date: Thu, 14 May 2026 17:34:50 -0300 Subject: [PATCH 2/3] Subscribe to visualViewport scroll events --- src/useVisualViewportSize/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/useVisualViewportSize/index.ts b/src/useVisualViewportSize/index.ts index 0d246db..f46448e 100644 --- a/src/useVisualViewportSize/index.ts +++ b/src/useVisualViewportSize/index.ts @@ -2,7 +2,11 @@ import { useSyncExternalStore } from "react"; function subscribe(callback: (this: VisualViewport, ev: Event) => unknown) { window.visualViewport?.addEventListener("resize", callback); - return () => window.visualViewport?.removeEventListener("resize", callback); + window.visualViewport?.addEventListener("scroll", callback); + return function () { + window.visualViewport?.removeEventListener("resize", callback); + window.visualViewport?.removeEventListener("scroll", callback); + }; } const getHeightSnapshot = () => From dff56fe159c56c835706e83678060f66c5a962eb Mon Sep 17 00:00:00 2001 From: Gonzalo D'elia Date: Thu, 14 May 2026 17:35:19 -0300 Subject: [PATCH 3/3] Provide SSR snapshot to useSyncExternalStore --- src/useVisualViewportSize/index.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/useVisualViewportSize/index.ts b/src/useVisualViewportSize/index.ts index f46448e..5bff697 100644 --- a/src/useVisualViewportSize/index.ts +++ b/src/useVisualViewportSize/index.ts @@ -3,7 +3,7 @@ import { useSyncExternalStore } from "react"; function subscribe(callback: (this: VisualViewport, ev: Event) => unknown) { window.visualViewport?.addEventListener("resize", callback); window.visualViewport?.addEventListener("scroll", callback); - return function () { + return function cleanup() { window.visualViewport?.removeEventListener("resize", callback); window.visualViewport?.removeEventListener("scroll", callback); }; @@ -20,7 +20,11 @@ const getOffsetTopSnapshot = () => : 0; export function useVisualViewportSize() { - const height = useSyncExternalStore(subscribe, getHeightSnapshot); - const offsetTop = useSyncExternalStore(subscribe, getOffsetTopSnapshot); + const height = useSyncExternalStore(subscribe, getHeightSnapshot, () => 0); + const offsetTop = useSyncExternalStore( + subscribe, + getOffsetTopSnapshot, + () => 0, + ); return { height, offsetTop }; }