Keyboard Avoider
A screen container that keeps its content and pinned footer clear of the software keyboard by the height the visual viewport actually reports — resize the frame, lift the frame, or ride only the bar.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/keyboard-avoider.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "KeyboardAvoider" component: the screen
container that keeps content and a pinned footer clear of the software keyboard.
React only — no gesture library, no animation library. It consumes one hook,
useVisualViewport, which subscribes to window.visualViewport and derives the
bottom occlusion as layoutHeight - (offsetTop + height) * scale, clamped at 0 and
rounded; inline an equivalent if you have none.
Why it exists (state this in the file's doc comment)
- A page has two viewports. The LAYOUT viewport is what 100vh, innerHeight and
position: fixed are resolved against; the VISUAL viewport is the part of it on
screen right now. The iOS keyboard shortens only the second, so a composer
pinned to the bottom of a 100dvh column is, as far as CSS is concerned, exactly
where it always was — underneath the keyboard the user is typing on.
- Therefore: never guess a height (300px is wrong the moment an IME candidate bar,
an autofill row, another language, an external keyboard or a rotation shows up),
never sniff a user agent, and never wait for a focus event and hope. Measure.
- Android caveat worth a comment: Chrome defaults to
interactive-widget=resizes-content, so the keyboard shrinks the LAYOUT viewport
too and the derived occlusion is 0 — the browser already moved the content. Set
interactive-widget=resizes-visual in the viewport meta to own the lift yourself
and get iOS's behaviour on both platforms.
Contract
- "use client". forwardRef<HTMLDivElement, KeyboardAvoiderProps> extending
React.HTMLAttributes<HTMLDivElement>; rest props spread onto the root.
- Export type KeyboardAvoiderVariant = "resize" | "lift" | "bar".
- Props:
- variant = "resize" — which part of the screen moves (see Behavior).
- inset?: number — CONTROLLED occlusion in px. When present the measurement is
ignored. This is the seam for a native shell (Capacitor / React Native
WebView) that already knows the keyboard height, for tests, and for previews
on a machine that has no software keyboard. Controlled values are taken at
face value: only the 0..maxInset clamp applies, never threshold.
- defaultInset = 0 — the inset used before the first measurement lands and in
browsers with no visualViewport at all (the honest fallback: 0 means "no
keyboard was ever reported", not "the keyboard is closed").
- onInsetChange?: (inset: number) => void — fires when the MEASURED occlusion
changes, including while inset is controlled, where it is the "the real
keyboard just did this" signal. Never echo the controlled prop back.
- threshold = 120, clamped 24..400 — measured occlusion that counts as a
keyboard. At rest iOS reports 1-3px of difference between the two viewports
and a floating Safari toolbar is worth ~50px; a layout that twitches at rest
is worse than one that waits.
- offset = 0, clamped 0..240 — extra px between the raised edge and the
keyboard, applied only while it is open.
- maxInset = 640, clamped 0..2000 — ceiling on the raise. The worst a wrong
number can then do is raise the screen too little, never push it off the top.
- footer?: ReactNode — the pinned bottom bar (a composer, a submit row). It is
what "bar" rides on the keyboard; with no footer, "bar" moves nothing.
- scrollable = true — the content region owns the scrolling. Turn it off when
the child brings its own scroller (a virtualised list, a stick-to-bottom
viewport) so there are never two nested scrollbars.
- keepFocusVisible = true — scroll the focused field back into the visible band
one frame after the raise lands.
- label = "Screen content" — accessible name of the scroll region.
- contentClassName / footerClassName — merged onto those two slots.
- Clamp every consumer number through one helper that rejects NaN, and clamp
defaultInset with the same ceiling as inset.
Behavior
- Effective inset:
measured = (viewport ready AND supported)
? (occlusion >= threshold ? occlusion : 0)
: defaultInset
keyboardInset = clamp(inset ?? measured, 0, maxInset)
open = keyboardInset > 0
raise = open ? keyboardInset + offset : 0
Below the threshold the reading is still honest, it is just spent as 0 rather
than as a 3px nudge.
- THE SAFE AREA AND THE KEYBOARD DO NOT STACK. The keyboard already covers the
home indicator, so the bottom inset is max(keyboard, safe-area), never a sum —
a sum leaves a dead 34px band above every keyboard. Two CSS expressions do the
whole job, no JS measurement of the safe area required:
padding: max(<raise>px, env(safe-area-inset-bottom, 0px))
move: translateY(min(0px, calc(env(safe-area-inset-bottom, 0px) - <raise>px)))
Both are identity at raise = 0, so the closed state needs no branch.
- Structure: root (position: relative, overflow: hidden, h-full) > frame
(box-border, flex column, h-full) > content region (flex-1, min-h-0) + optional
footer (shrink-0). The root must be given a height by its parent — a screen
column, a fixed frame — because a percentage height of nothing is nothing.
- variant = "resize": the frame's padding-bottom becomes max(raise, safe-area).
Border-box means the content box shrinks, so the flex-1 scroll region gets
shorter and the footer lands on the keyboard. Everything stays on screen; the
content reflows.
- variant = "lift": the frame is translated by the move expression. Composited,
nothing reflows, but the top of the screen rides off the edge — the root's
overflow: hidden is what clips it, so it belongs on the component, not on the
consumer's page. Note in a comment that a transform makes that element the
containing block for any position: fixed descendant, so overlays inside a
lifted (or "bar") screen must be portalled to the body.
- variant = "bar": only the footer is translated; the content region is not
touched at all, so nothing reflows and the keys simply cover the tail of the
list. Give the footer an opaque background — it now floats over the content.
- FOLLOW, DO NOT ANIMATE, A KEYBOARD THAT ANIMATES ITSELF. iOS streams the height
frame by frame while the keyboard slides in; a CSS transition on top of that
visibly lags behind the keys. So: compare the new raise with the previous one
(state adjusted DURING RENDER, not in an effect — the transition class has to be
on the element in the same commit that moves it) and only apply
transition-[padding-bottom,transform] 200ms ease-out when the change is bigger
than ~48px, which is a platform reporting only the end state (Android usually
jumps 0 -> 320 in one event).
- keepFocusVisible: one frame after the raise lands (requestAnimationFrame, so the
padding / transform is applied first), if document.activeElement is inside the
root, call scrollIntoView({ block: "nearest" }) on it. "nearest" scrolls the
minimum and does nothing when the field is already visible. Pass NO behavior:
the scroll region carries scroll-smooth + motion-reduce:scroll-auto, so the
reduced-motion preference decides. Cancel the frame on cleanup.
- Publish the applied raise as a CSS custom property --keyboard-inset on the root,
plus data-keyboard="open|closed" and data-variant, so consumers can pad a
floating action button or a map control off the same number without measuring
it twice.
- Cleanup: every viewport listener lives in the hook and is removed on unmount and
on any option change; the only timer this component owns is that one rAF, and it
is cancelled in the effect's cleanup.
Rendering & styling
- Semantic tokens only, no colour literals. The component itself paints almost
nothing — root/frame are transparent; the only visible styling is the scroll
region's focus ring (focus-visible:ring-2 ring-ring ring-inset) — so it inherits
whatever the screen inside it is made of.
- Content region: min-h-0 flex-1 plus, when scrollable, overflow-y-auto
overscroll-contain touch-pan-y. overscroll-contain, not none: this component
hijacks no gesture, it just must not scroll the page behind a screen that the
keyboard has already made short.
- Accessibility:
- Keyboard map: the scroll region is role="region" + aria-label + tabIndex 0, so
every native scroll key (Arrow keys, PageUp / PageDown, Home / End, Space)
works inside it. Deliberately NO custom key handler and no Escape-to-dismiss:
the software keyboard belongs to the platform, and rebinding a key inside a
scroll region would break the behaviour that region exists for.
- No live region. A keyboard opening is not news — the user opened it — and
announcing it would talk over the field they just focused.
- Nothing here is announced as disabled, nothing unmounts and nothing takes
focus away: a layout container that moved focus would be a bug, because the
field the user is typing in is the entire point.
- Under prefers-reduced-motion every transition is dropped
(motion-reduce:transition-none) and the smooth scroll becomes an instant one.
The feature is unchanged: the raise still happens, just without the ease.
- No hover-only affordances and no minimum touch target of its own: the component
ships no controls at all. Anything it raises (your composer) keeps its own >=44px
hit areas.
Customization levers
- variant is the one real decision. resize = nothing leaves the screen, at the cost
of a reflow. lift = zero reflow, at the cost of the app bar. bar = zero reflow
and zero movement of the content, at the cost of the list's tail being covered.
- threshold is the noise floor (raise it to ~180 if a browser toolbar keeps
arming it) and maxInset is the lie ceiling (drop it to your tallest real
keyboard when a native bridge feeds the number).
- offset parks the raised edge a few px above the keys, or clears a floating tab
bar that lives between the composer and the bottom edge.
- Slots: contentClassName is where the screen's padding goes; footerClassName is
where the composer's chrome goes; data-slot="keyboard-avoider-frame" /
"-content" / "-footer" are stable hooks for a design-system override.
- An app bar is a sticky top-0 first child of the content region: under resize it
stays pinned, under lift it rides off the top. That is the difference, in one
line of consumer CSS.
- Use --keyboard-inset for anything the component does not own: pb-[var(--keyboard-inset)]
on a floating button, a map's bottom control inset, a sticky section header.
- Pair, do not merge: a bottom-anchored chat list goes INSIDE the content region
with scrollable={false}, and the composer goes in footer. Keep this component
about one thing — spending the keyboard's height as layout.Concepts
- Two viewports, one keyboard — the layout viewport (what
100vh,innerHeightandposition: fixedresolve against) does not move when the iOS keyboard opens; only the visual viewport gets shorter. That gap is the keyboard, andlayoutHeight - (offsetTop + height) * scaleis how you read it. Multiplying byscaleis what stops a pinch-zoom from being mistaken for a keyboard, and rounding is what stops a half-pixel wobble from re-rendering forever. - Measured, never guessed — the height changes with the language, the IME candidate bar, the autofill row, an external keyboard and the orientation, so a hard-coded 300px is wrong on most of the phones that matter. The threshold below which nothing moves exists for the opposite reason: at rest the two viewports disagree by a few pixels, and a layout that twitches when nobody is typing is worse than one that waits.
max(), never a sum — the keyboard already covers the home indicator, so the bottom inset ismax(keyboard, env(safe-area-inset-bottom)). Adding them leaves a 34px band of nothing above every keyboard; both CSS expressions here collapse to the safe area on their own when the keyboard is closed, so the resting state needs no special case.- Three ways out of the way, three different bills —
resizepays in reflow and keeps every pixel on screen;liftpays with the app bar sliding off the top and reflows nothing;barmoves only the pinned footer and leaves the list untouched, so the keys cover its tail. There is no free option, which is why this is a prop and not a heuristic. - Follow a keyboard that animates itself — iOS streams the height frame by frame, and a CSS transition layered on top of it lags visibly behind the keys, so small continuous deltas are applied raw and only a single jump larger than ~48px (a platform reporting just the end state) is eased. Reduced motion drops the ease and keeps the raise: the feature is the layout, not the animation.
- The number is a prop —
insetmakes the whole thing drivable from a native shell that already knows the keyboard height, from a test, or from a preview on a laptop with no software keyboard, whileonInsetChangestill reports what the real viewport did.maxInsetis the ceiling on that trust: the worst a wrong number can do is raise the screen too little, never push it off the top.
Keyboard Accessory
The bar that rides on top of the software keyboard: previous / next field travel, quick-insert chips, and a Done key that hands focus back on purpose.
Wheel Picker
An iOS-style drum picker: scroll-snap detents under the thumb, optional wrapping, unlandable rows, and the same turns from the keyboard and two chevrons.