@sigx/lynx-keyboard
v0.28.0
Published
Soft-keyboard handling for sigx-lynx — KeyboardStickyView, KeyboardAvoidingView and keyboard hooks
Maintainers
Readme
@sigx/lynx-keyboard
Soft-keyboard handling for SignalX on Lynx, with an API mirroring React Native's (KeyboardAvoidingView, KeyboardStickyView/InputAccessoryView, useKeyboard). Keeps a composer input — and an accessory toolbar above it — pinned to the top edge of the on-screen keyboard.
Keyboard height reaches JS through the safe-area bridge (@sigx/lynx-safe-area): the native publisher reports the IME height as the keyboard inset on every safeAreaChanged event. This package turns that inset into ready-made layout primitives — no extra native module needed. Keyboard handling stays a separate concern from safe-area, mirroring the RN ecosystem split (react-native core / react-native-keyboard-controller vs react-native-safe-area-context).
📚 Documentation
Full guides, API reference and live examples → https://sigx.dev/lynx/modules/keyboard/overview/
Install
pnpm add @sigx/lynx-keyboardRequires <SafeAreaProvider> (from @sigx/lynx-safe-area) at the app root — the same provider every safe-area hook already needs.
Usage
The proven chat-screen shape: the content area shrinks (KeyboardAvoidingView), the composer bar rides the keyboard (KeyboardStickyView). The bar's translate and the area's padding are both max(0, keyboard - bottomInset), so the list bottom always ends exactly where the bar lands.
import { component } from '@sigx/lynx';
import { KeyboardAvoidingView, KeyboardStickyView } from '@sigx/lynx-keyboard';
const ChatScreen = component(() => () => (
<view style={{ display: 'flex', flexDirection: 'column', flexGrow: 1, flexShrink: 1, flexBasis: 0 }}>
<KeyboardAvoidingView behavior="padding">
<scroll-view style={{ flexGrow: 1, flexShrink: 1, flexBasis: 0 }}>
{/* messages */}
</scroll-view>
</KeyboardAvoidingView>
<KeyboardStickyView>
{/* toolbar row (formatting buttons, attachments, …) */}
{/* input row */}
</KeyboardStickyView>
</view>
));Use one primitive per subtree: a bar inside both a padding KeyboardAvoidingView and a KeyboardStickyView lifts twice.
API
<KeyboardStickyView>
Pins its children to the keyboard's top edge with an MT-animated translateY (smooth 60fps, no per-frame thread crossing). When the keyboard is closed the bar rests in its natural flex position. Aliases: KeyboardAccessoryView, KeyboardToolbar.
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| offset | number | 0 | Extra gap (dp) above the keyboard. |
| animated | boolean | true | false = discrete BG re-render (debug fallback). |
| discountBottomInset | boolean | true | Subtract the bottom safe-area inset from the lift. Keep true when an ancestor <SafeAreaView edges={['bottom']}> already pads the home indicator. |
| pinned | boolean | false | Freeze the bar in flow (translateY pinned to 0, instantly). For the WhatsApp keyboard ⇄ emoji-panel swap: pin in the same frame an app panel takes the keyboard's remembered lift as flow height and the bar never moves — the system keyboard's own show/hide animation does all visible motion, covering or revealing the already-painted panel (see @sigx/lynx-emoji's KeyboardPanelPicker and the showcase Chat composer). |
| extraLiftSV | SharedValue<number> | — | An extra lift the bar must also clear: it rides max(keyboardLift, extraLiftSV). Pass a bottom accessory's live height — e.g. @sigx/lynx-navigation's useSheetHeight() for an emoji sheet — so the bar sits above whichever of the keyboard or the accessory is taller, riding the finger frame-for-frame during a drag (folded on the MT via useDerivedValue([…], 'max')). Because it's a max, the keyboard ⇄ sheet swap is dip-free as long as one shrinks while the other grows. Must be in the same unit as the lift (dp). |
Note: the bar's transform is controlled internally (the MT binding writes translateY via setStyleProperties; the non-animated path writes an inline transform). A transform passed through style will be overridden — wrap children in their own view if you need an additional transform.
<KeyboardAvoidingView>
Wraps content and keeps it above the keyboard. Layout-affecting, so it applies inline BG styles (the same pattern as <SafeAreaView> — MT-driven layout writes don't reflow <scroll-view>).
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| behavior | 'padding' \| 'translate' \| 'height' | 'padding' | padding shrinks the column; translate shifts it; height appends a spacer. |
| keyboardVerticalOffset | number | 0 | Added to the computed lift (RN parity). |
| discountBottomInset | boolean | true | Same as on KeyboardStickyView — set false to lift by the full keyboard height when no ancestor pads the bottom inset. |
| pinned | boolean | false | Freeze avoidance (lift 0) while a sibling already holds the keyboard's space in flow — pair with <KeyboardStickyView pinned>. |
| extraLiftSV | SharedValue<number> | — | Also shrink by an accessory's live height: content shrinks by max(keyboardLift, extraLiftSV). Pass e.g. @sigx/lynx-navigation's useSheetHeight() so an emoji sheet overlaying the bottom pushes the thread up too, not only the keyboard. Symmetric to KeyboardStickyView's extraLiftSV. |
Hooks
useKeyboard(): Computed<{ height, visible }>— BG-reactive keyboard state.useKeyboardLift(discountBottomInset?, offset?): Computed<number>— the raw lift value.useKeyboardLiftSV(discountBottomInset?, offset?, duration?): SharedValue<number>— smoothly animated MT SharedValue tracking the lift; bind withuseAnimatedStyle(ref, sv, 'translateY', { factor: -1 }).durationis in seconds (the@sigx/lynx-motionconvention), default0.25.rememberedKeyboardLift(): number— the LAST observed lift, or0if the keyboard has never been shown. See below. Like every length in this package it is in Lynx logical pixels — the unit@sigx/lynx-safe-areareports insets in, which the docs there call dp/pt.resetRememberedKeyboardLift(): void— clears that memory, including the persisted copy. A test seam, not app API: calling it from a screen throws away the app-wide measurement every keyboard-sized panel seeds itself from.
Remembered keyboard height
A panel that must occupy exactly the keyboard's space — a WhatsApp-style emoji sheet — has to know how tall the keyboard is before showing it. "How tall is this device's keyboard" is a fact about the app's environment, not about whichever component was mounted when it last appeared, so the observation is module-level and persisted:
- any mounted
useKeyboardLift()/useKeyboardLiftSV()with the default shape (bottom inset discounted, no offset) records what it sees; - the value survives the keyboard closing, screens unmounting, and app restarts (via the optional
@sigx/lynx-storagepeer — without it the memory is simply per-run); - a real observation always supersedes a restored one, even if shorter (a different IME or split screen genuinely changes the height), so a stale value can never strand a panel too tall.
@sigx/lynx-sheet seeds its { keyboard: true } detent from this. Without it, the first keyboard-sized panel opened on a screen where nothing has been typed yet falls back to fallbackPx and then visibly corrects itself the moment the real keyboard returns — 53 px of input-row jump on a Pixel 9 Pro XL (#811).
Web
Renders on web (sigx run:web), but nothing ever lifts. Everything here is pure JS and nothing throws — <KeyboardAvoidingView> and <KeyboardStickyView> mount, the hooks return live signals, rememberedKeyboardLift() answers 0. The height they all read is @sigx/lynx-safe-area's keyboard inset, and on web that package has no publisher behind it, so the inset stays 0 for the session.
That is the right answer for a desktop browser, where the keyboard is hardware. It is the wrong one for a mobile browser or an installed PWA, whose on-screen keyboard resizes the visual viewport — a web shim would publish visualViewport height changes into the same safe-area globalProps key, and every primitive here would start working unchanged. Not wired today, so don't ship a web build that depends on the lift.
Gotchas
- One primitive per subtree. A bar inside both a
behavior="padding"<KeyboardAvoidingView>and a<KeyboardStickyView>lifts twice. Pick the one that matches the shape. - The sticky lift is invisible to layout measurement.
translateYis written on the main thread viasetStyleProperties, so anything measuring the bar's position from the background thread — a popup anchored to a caret, an absolute overlay — reads its un-lifted box. Anchor to the sameuseKeyboardLiftSV()instead of measuring. transformon<KeyboardStickyView>is overridden. The component owns that property. Wrap the children in a view of your own if you need another transform.discountBottomInsetdefaults totrue. The keyboard covers the home-indicator region, so a bar already padded by<SafeAreaView edges={['bottom']}>must only rise by the difference. Set itfalsewhen nothing above you pads the bottom inset, or the bar sits too low by exactly that inset.durationis seconds.useKeyboardLiftSV(…, duration)follows the@sigx/lynx-motionconvention, not milliseconds —0.25, not250.- Won't do, and why: no prev/next/done accessory toolbar (RN's
InputAccessoryViewrole is covered by<KeyboardStickyView>— build the row you want inside it); no interactive/drag-to-dismiss keyboard (Lynx exposes no interactive dismiss gesture); no separate keyboard window; and a singleoffsetrather than the peer's closed/opened pair, because the closed position is the natural flex position here.
How it works
- Height source —
useSafeAreaInsets().value.keyboard. There is no separate keyboard event API in Lynx; the safe-area publisher is canonical. - The lift —
max(0, keyboard - bottomInset): the keyboard covers the home-indicator region, so a bar that already sits above the bottom inset only needs to rise by the difference. Never add both. - BG→MT bridge — the keyboard inset is a BG-only signal (deliberately not a SharedValue in lynx-safe-area).
useKeyboardLiftSVwatches it from a BG effect and dispatches an MTwithTiming(from@sigx/lynx-motion) toward each new target; the tween then runs entirely on the main thread. - Transform vs layout — only
translateYis MT-animated. Padding/height go through inline BG styles because MT layout writes land after the first layout pass and<scroll-view>won't reflow.
Demo
See the Keyboard screen in examples/showcase — Input & Keyboard → Keyboard (route /keyboard).
License
MIT
