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.

10:58
‹
◎ 💬 ↥
🗑⚡👍
✕
H
Write a comment↑
qwertyuiop
asdfghjkl
⇧zxcvbnm⌫
123spacereturn
Today. The card is a free-floating object that has to be placed. Here it flipped above the block — and covers the page it is a comment about.

Why the placement keeps fighting us

  • 1 Two axes of guessing. The card starts at an assumed ESTIMATED_HEIGHT = 180, measures itself in onLayout, then moves — the first frame is in the wrong place.
  • 2 The keyboard is modelled by hand: keyboardWillShow listeners shrink the container and a Reanimated withTiming(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-down message 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.

10:58
❤️🙌🔥👏😢😍 ⊕
Send message
Instagram story reply. Six emoji in a row directly above the message field. Tap one and it sends immediately to the author's DMs. The ⊕ at the end opens the full emoji keyboard.
10:58
on it
❤️👍👎😂‼️❓
shipped the sheet — take a look?
nice
Messages tapbacks. Long-press a bubble and six reactions arrive in a floating glass capsule pinned to that bubble. The reaction attaches to the message, not to the conversation.

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.

10:58
‹
◎ 💬 ↥
The relay reconnects on a backoff capped at 30s…
🗑Remove
👍Looks good
⋯More
H
Write a comment↑
Resting state. fitToContents, keyboard down, glass over the page. Two quick replies are one tap; "More" is last, per rule 4.
10:58
‹
◎ 💬 ↥
The relay reconnects on a backoff capped at 30s…
🗑Remove
👍Looks good
⋯More
H
this number is stale↑
qwertyuiop
asdfghjkl
⇧zxcvbnm⌫
123spacereturn
Typing. UIKit lifts the sheet with the keyboard and squares off its bottom corners. The quick row stays — rule 1. We scroll the WebView so the highlight stays in the strip that's left.

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.
01 Enter comment mode

Viewfinder button in the glass toolbar, unchanged.

02 Tap a block

Haptic, blue highlight, anchor stored via setPick — unchanged.

03 Sheet rises

Keyboard down. Page scrolls so the highlight clears the sheet.

04 Quick reply, or type

One tap posts and dismisses. Tapping the field raises the keyboard and the sheet with it.

05 Send

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.

ShapeWhat it costs
Take

expo-router formSheet route

pages/[slug]/comment, sheetAllowedDetents: "fitToContents", sheetGrabberVisible, anchor read from pageCommentStore.

Matches the mobile convention and the line-comment precedent, and it is the only option that gets Liquid Glass from the system rather than imitating it.

Costs a route transition and moves submit to another screen — but usePageComments is keyed by page and version, so the store is already the shared surface.

Skip

In-screen sheet

An absolutely positioned view with useAnimatedKeyboard, like ReplyBar.

Keeps state local and skips the transition, but re-implements detents, grabber, drag-to-dismiss and the dim layer — and would need GlassView to fake a material the sheet gets natively. This is the popover's problem in a new position.

Skip

Composer overlay, no sheet

The native module pinned to the bottom with backdrop: "dim", using its own expand behaviour.

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.

OptionWhat you getWhat it costs

Keep CommentComposer

RN TextInput + avatar + send button.

Already themed with uniwind, already used by ReplyBar and the all-comments sheet, so comment input looks the same everywhere. Pure JS: iterate in seconds.

No dictation, no paste handling, no native height reporting. The quick row stays RN buttons we draw ourselves.

Adopt the Composer module

quickKeys + backdrop: "passthrough".

The quick replies become the native quickKeys strip — SF Symbols, onQuickKeyPress by id, dividers between groups. That is rules 1–4 already implemented in Swift. Plus dictation, paste, isSending, and onHeightChange to size the sheet.

Built for agent sessions: models, session tabs, slash commands and attachments all passed empty. Its expand/backdrop machinery overlaps the sheet, so passthrough is mandatory. quickKeys are glyph-only, which loses the caption rule 5 wants. And iterating means a native build (-scheme Composer).

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

 PathNote
+app/(authenticated)/pages/[slug]/comment.tsx

Route re-export, plus the Stack.Screen entry with fitToContents and glassHeaderOptions.

+screens/…/pages/[slug]/comment/CommentSheet.tsx

Quoted anchor, quick row, composer. Modelled on LineCommentSheet.

−screens/…/components/CommentPopover/

Gone: the card, the keyboard listeners, the ✕, the withTiming settle.

~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. packages/ui still uses it for desktop so the function stays — but insetTop and the flip clamp exist only for mobile and can come back out.

~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 createAnchored; loses the popover branch; gains the scroll-into-view call.

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