Mobile

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { useVisualViewport } from "@/registry/hooks/use-visual-viewport"

/**
 * How the screen gets out of the keyboard's way.
 *
 * - `resize` — the frame keeps its box and grows its bottom padding, so the
 *   scroll region shrinks and the footer lands on the keyboard. Everything stays
 *   on screen; the content reflows.
 * - `lift` — the whole frame is translated up. Composited, never reflows, but the

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/keyboard-avoider.json

Prompt

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, innerHeight and position: fixed resolve against) does not move when the iOS keyboard opens; only the visual viewport gets shorter. That gap is the keyboard, and layoutHeight - (offsetTop + height) * scale is how you read it. Multiplying by scale is 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 is max(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 billsresize pays in reflow and keeps every pixel on screen; lift pays with the app bar sliding off the top and reflows nothing; bar moves 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 propinset makes 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, while onInsetChange still reports what the real viewport did. maxInset is the ceiling on that trust: the worst a wrong number can do is raise the screen too little, never push it off the top.

On This Page