apps/mobile · page comments
One sheet, not a floating card
Replacing the anchored comment popover with a single Liquid Glass sheet that carries the quick replies and the composer — built the way Instagram, Snapchat and Messages build the same thing.
What's on screen today
Tap a block in comment mode and a card is placed beside it by
popoverPlacement(), which flips it above or below the
block, clamps it under the header, and subtracts the keyboard from the
container so it has somewhere to land. Quick feedback is a second
surface, pushed from inside that card.
Why the placement keeps fighting us
-
1
Two axes of guessing. The card starts at an assumed
ESTIMATED_HEIGHT = 180, measures itself inonLayout, then moves — the first frame is in the wrong place. -
2
The keyboard is modelled by hand:
keyboardWillShowlisteners shrink the container and a ReanimatedwithTiming(250ms)approximates a curve UIKit owns. - 3 On a tall block there is nowhere good to go. The flip puts the card over the selection; the clamp pins it under the glass header. Both branches hide what you are commenting on.
-
4
Quick feedback is a different surface — ⚡ pushes
pages/[slug]/quick, a real formSheet, on top of a card that is imitating one. -
5
Dismissal is hand-built: an ✕ in the card plus a
pointer-downmessage from inside the WebView to catch outside taps. - 6 It is a rectangle we drew. Nothing about it is a system surface, so it gets no glass, no grabber, no drag, and no keyboard choreography.
How everyone else does quick replies
Instagram, Snapchat, WhatsApp and Messages all solved this, and they converged on nearly the same object. Worth copying the parts that are settled rather than re-deciding them.
The five rules they agree on
- 1 The row lives immediately above the input, in the same container. It never appears as its own screen, and it rides the keyboard up rather than being replaced by it.
- 2 One tap sends. No confirm, no second step, no "are you sure" — haptic, send, dismiss. The whole point is that it is faster than typing.
- 3 Six or fewer, fixed. Instagram ships six emoji, Messages six tapbacks, Snapchat seven Bitmoji reactions. Past that it stops being scannable and becomes a menu.
- 4 "More" goes at the end. Instagram's ⊕ and WhatsApp's + are the last item in the row and open the full picker. The escape hatch never sits in the middle of the fast path.
- 5 The target is quoted. Instagram shows the story behind the bar, Snapchat attaches the reaction under the message, WhatsApp quotes the bubble. You always see what you are replying to while you reply.
Two places our case differs, and what to do about it.
Our quick replies are actions, not emotions. ❤️ needs no label in any language; "ask for this to be removed" does. Glyph-only is why those rows read as reaction bars rather than forms — but the current trash / ⚡ / thumbs-up row is already glyph-only and already ambiguous at a glance. Adopt the shape, not the bareness: SF Symbol with a one-word caption under it.
⚡ is in the wrong position. It is the "more" affordance and it currently sits between the two fast actions. By rule 4 it belongs last: Remove · Looks good · More.
The proposal
Selecting a block opens one sheet, at a detent that fits its contents. On iOS 26 a partial-detent sheet is Liquid Glass by default and detaches from the screen edges, so the page stays visible behind and around it — which is exactly the property the floating card was trying to buy and kept losing.
fitToContents, keyboard down,
glass over the page. Two quick replies are one tap; "More" is last,
per rule 4.
What the system gives us for free
- ✓Glass. A partial-detent sheet on iOS 26 gets the material, the specular edge and the shadow with no code — the same language as the toolbar capsules already floating over this page.
- ✓Placement. Always bottom, always full width, always a height that fits its contents.
- ✓Keyboard. UIKit raises and lowers it on the real curve. Delete the listeners and the timing constant.
- ✓Dismissal. Drag the grabber, tap outside, swipe — all native, all one
router.back()that clears the selection. - ✓Room to grow. A thread list, an intent picker, an attachment row all have somewhere to live. The card had nowhere.
Viewfinder button in the glass toolbar, unchanged.
Haptic, blue highlight, anchor stored via setPick — unchanged.
Keyboard down. Page scrolls so the highlight clears the sheet.
One tap posts and dismisses. Tapping the field raises the keyboard and the sheet with it.
Thread created, sheet closes, selection clears, pin lands on the block.
How to build the container
Three shapes are available, and the repo already has a precedent for
one: workspace/[id]/line-comment is this exact thing — a
comment composer in a formSheet route, fed its anchor through a store.
| Shape | What it costs | |
|---|---|---|
| Take |
expo-router formSheet route
|
Matches the mobile convention and the Costs a route transition and moves submit to another screen — but |
| Skip | In-screen sheet An absolutely positioned view with |
Keeps state local and skips the transition, but re-implements detents, grabber, drag-to-dismiss and the dim layer — and would need |
| Skip | Composer overlay, no sheet The native module pinned to the bottom with |
Fewest moving parts on paper, but its expanded mode claims the whole screen. It is built for "write a prompt", not "annotate the thing you can see". |
The one thing the sheet doesn't solve: the block can end up underneath it.
No protocol change needed. The host already knows the selection's rect
(rects[PENDING_ANCHOR_ID], kept live by the
track message) and the current offset
(scrollYRef), so it can send the existing
restore-scroll with
y = scrollY + rect.top − targetTop when the sheet opens,
and again when the keyboard changes the visible strip.
Known trap: the Liquid Glass flare on formSheet.
react-native-screens never sets
UISheetPresentationController.backgroundEffect (iOS 26.1),
so a partial-detent sheet keeps the material's interactive
glass: a bright specular highlight chases your finger across the
sheet, and on a dark background it reads as a glowing blob. Filed
September 2026 against 4.26.2 / 4.27.0 — we are on 4.26.0 — and still
open. The reporter's patch sets a UIColorEffect matching
the sheet background. Budget for either a patch or a plainer sheet
background until it lands upstream.
"Can we use native text input?"
Worth splitting: TextInput is already a real
UITextView. The live choice is whether comments adopt
modules/composer, the SwiftUI composer that Home, the
terminal and files-changed already use.
| Option | What you get | What it costs |
|---|---|---|
Keep RN |
Already themed with uniwind, already used by |
No dictation, no paste handling, no native height reporting. The quick row stays RN buttons we draw ourselves. |
Adopt the
|
The quick replies become the native |
Built for agent sessions: models, session tabs, slash commands and attachments all passed empty. Its expand/backdrop machinery overlaps the sheet, so |
Recommendation: ship the sheet with the RN composer, then swap.
The sheet is the change that removes the bugs; the input is a swap
behind two props (onSubmit, pending). Doing
both at once puts a pod rebuild between us and knowing whether the
layout is right. If the quick-keys strip turns out to be the
look you want, it lands as a second commit with nothing else moving.
What changes in the tree
| Path | Note | |
|---|---|---|
| + | app/(authenticated)/pages/[slug]/comment.tsx | Route re-export, plus the |
| + | screens/…/pages/[slug]/comment/CommentSheet.tsx | Quoted anchor, quick row, composer. Modelled on |
| − | screens/…/components/CommentPopover/ | Gone: the card, the keyboard listeners, the ✕, the |
| ~ | screens/…/components/ComposerActions/ | Reorders to Remove · Looks good · More and gains captions. Still shared with whatever renders it. |
| − | packages/shared/src/page-comments/popoverLayout.ts | Mobile stops calling it. |
| ~ | app/(authenticated)/pages/[slug]/quick.tsx | Becomes the "More" expansion inside the sheet rather than a sheet of its own. |
| ~ | PageDetailScreen.tsx | Keeps selection, highlight, pins and |
Note on the working tree. The in-flight insetTop
work — popoverLayout.ts, its tests, and the
headerHeight plumbing — exists to keep the floating card
clear of the glass header. The sheet makes that unnecessary on
mobile. The PageFrame contentInset fix is
unrelated and should stay either way.
Open questions
Three calls worth making before anyone writes code.
1. Does the keyboard come up on open?
The popover autofocuses, and Instagram raises the keyboard with its reaction row — but Instagram's sheet is the whole screen, so it costs nothing. Ours has to leave the page visible, and autofocus spends half the screen before you have decided whether to type.
Recommend: open with the keyboard down. Quick replies are one tap; the field is one more.
2. Captions under the quick replies, or glyphs alone?
Glyph-only is what makes these rows feel like reaction bars, and it is
what the native quickKeys strip gives us. But 🗑 on a
document means "delete this comment" at least as readily as "ask for
this to be removed".
Recommend: SF Symbol with a one-word caption. Revisit once the labels have earned their recognition.
3. Does the sheet show the thread when one already exists?
Tapping an existing pin opens the full all-comments sheet with focus. With a per-anchor sheet in place, a pin could open the same sheet showing that thread with a reply field — which is how every app in the prior art handles it.
Recommend: out of scope here. Get new comments onto one surface first, unify pins after.
Sources
- Instagram's quick-response emoji for Stories — Social Media Today
- How to react on an Instagram story — the six-emoji bar and the ⊕ full-keyboard escape
- Snapchat emoji reactions — long-press, tap, attaches under the message
- Snapchat Bitmoji reactions — the fixed set of seven
- Presenting Liquid Glass sheets in SwiftUI on iOS 26 — partial detents float and use glass; large detent turns opaque
- react-native-screens #4605 — the formSheet glass flare, still open