Components
IMessageGlass
The iOS 26 Messages surface, calibrated 1:1 against an iPhone 15 Plus: a live interactive thread whose floating chrome (nav capsules, input pill) is real engine glass that refracts the messages scrolling behind it. One component renders the whole phone — status bar, header, thread, composer — in any size container. Supports 1:1 conversations (contact) and multi-sender group chats (group) with sender labels, gutter avatars and the member-cluster header.
"use client";
import { useState } from "react";
import { IMessageGlass, type GlassMessage } from "imessage-ui";
const CONTACT = {
name: "Planner",
avatar: { initial: "P", gradient: ["#ADC1DE", "#767FB5"] as [string, string] },
avatarDark: { initial: "P", gradient: ["#575268", "#302749"] as [string, string] },
};
export default function IMessageGlassBasic() {
const [messages, setMessages] = useState<GlassMessage[]>([
{ id: 0, from: "them", text: "This whole phone is one component — thread, glass chrome, status bar." },
{ id: 1, from: "me", text: "And the capsules genuinely refract the messages?" },
{
id: 2,
from: "them",
text: "Type something and scroll — the header bends the thread through real engine glass 👀",
stamp: { day: "Today", time: "9:41 AM" },
},
]);
const [draft, setDraft] = useState("");
return (
<div style={{ width: 390, height: 660, maxWidth: "100%", borderRadius: 28, overflow: "hidden" }}>
<IMessageGlass
theme="dark"
contact={CONTACT}
messages={messages}
value={draft}
onChange={setDraft}
onSend={(text) =>
setMessages((m) => [...m, { id: Date.now(), from: "me", text }])
}
unreadCount={3}
safeTop={24}
safeBottom={20}
/>
</div>
);
}Live and interactive: type, send, scroll. The header capsules and input pill refract the thread through the liquid-glass engine (Chromium bends; other browsers frost). The parent container gives it its size — metrics are absolute iOS points, so nothing stretches.
Usage
import { IMessageGlass } from "imessage-ui";
<div style={{ width: 390, height: 700 }}>
<IMessageGlass
theme="dark"
contact={{ name: "Planner", avatar: { initial: "P", gradient: ["#ADC1DE", "#767FB5"] } }}
messages={messages}
value={draft}
onChange={setDraft}
onSend={send}
/>
</div>Examples
Group chat
"use client";
import { useState } from "react";
import { IMessageGlass, type GlassGroup, type GlassMessage } from "imessage-ui";
const GROUP: GlassGroup = {
name: "Weekend Plans",
members: [
{ name: "April", avatar: { initial: "A", gradient: ["#E8B7D4", "#A5BFE1"] as [string, string] } },
{ name: "Michael", avatar: { initial: "M", gradient: ["#B7E1C0", "#6FA97E"] as [string, string] } },
{ name: "Evan", avatar: { initial: "E", gradient: ["#E1CBA5", "#B08D57"] as [string, string] } },
],
};
export default function IMessageGlassGroup() {
const [messages, setMessages] = useState<GlassMessage[]>([
{ id: 0, from: "them", sender: "April", text: "ok so who's actually in for Saturday" },
{ id: 1, from: "them", sender: "Michael", text: "in. what time" },
{ id: 2, from: "me", text: "I can do after 2", replies: 1 },
// `replyTo` draws iOS's inline-reply connector from message 2 down the
// gutter to this one (chains of 3+ upgrade to the thread spine).
{ id: 3, from: "them", sender: "Evan", replyTo: 2, text: "same, 2 works" },
// `burst: true` splits a same-sender run the way iOS does when messages
// land minutes apart — fresh sender label + its own gutter avatar.
{ id: 4, from: "them", sender: "Evan", text: "wait actually make it 3, I have a thing", burst: true },
]);
const [draft, setDraft] = useState("");
return (
<div style={{ width: 390, height: 660, maxWidth: "100%", borderRadius: 28, overflow: "hidden" }}>
<IMessageGlass
theme="light"
group={GROUP}
messages={messages}
// In groups, typing carries that member's gutter avatar (no label —
// iOS shows avatar + thought-bubble only).
typing={{ sender: "April" }}
value={draft}
onChange={setDraft}
onSend={(text) =>
setMessages((m) => [...m, { id: Date.now(), from: "me", text }])
}
safeTop={24}
safeBottom={20}
/>
</div>
);
}Pass group instead of contact and set sender on incoming messages: sender labels above each burst, member avatars in the gutter, the header's mini-avatar cluster, and no Delivered receipt — all measured from device captures. burst forces an iOS-style run split for messages minutes apart; replyTo draws the inline-reply connector; typing={{ sender }} appends that member's typing thought-bubble.
API reference
IMessageGlass
| Prop | Type | Default | Description |
|---|---|---|---|
| messages* | GlassMessage[] | — | The thread, in order. Layout, grouping, tails and receipts are derived. |
| contact | GlassContact | — | 1:1 peer — header avatar + name pill. Provide contact OR group. |
| group | GlassGroup | — | Group conversation — member cluster header, sender labels and gutter avatars. Provide contact OR group. |
| typing | boolean | { sender?: string } | — | Show the three-dot typing thought-bubble pinned after the thread. In groups pass { sender } so it carries that member's gutter avatar (iOS shows no sender label above the typing bubble). |
| theme | "light" | "dark" | "light" | iOS appearance; drives every sampled color token. |
| wallpaper | string | — | Chat wallpaper (image URL or CSS background). Omit for the solid theme backdrop. |
| value / onChange / onSend | string / (next) => void / (text) => void | — | Controlled composer. Omit value for uncontrolled typing (onSend still fires); the empty composer shows a mic glyph in 1:1 chats and a waveform in groups, as measured. |
| placeholder | string | "iMessage" | Composer placeholder text. |
| unreadCount | number | string | — | Badge inside the back capsule. |
| onBack / onFaceTime / onPlus | () => void | — | Chrome tap handlers (back capsule, name pill / FaceTime, "+" button). |
| onReplies | (id: GlassMessage["id"]) => void | — | Tap handler for a message's "N Replies" link (receives that message's id). The link renders and is focusable either way. |
| safeTop / safeBottom | number | 12 / 16 | Status-bar / home-indicator insets in px. Simulate a full phone with device values (e.g. 59 / 34 on a 15 Plus); keep the small defaults when embedding. |
| statusBar | boolean | true | Render the iOS status bar (clock, signal, wifi, battery) in the safe-area zone. |
| statusTime / statusSignal / statusBattery / statusLocation | string / number / number / boolean | — | Status-bar readouts (defaults: "9:41", 4 bars, 100%, no arrow). |
| statusCharging | boolean | false | Charging battery pill: iOS-green fill with a white percentage and lightning bolt (measured off device captures). |
| headerScrim | "wash" | "dim" | "wash" | The header's scroll-edge treatment — iOS's material differs between capture sessions, so it is selectable. "wash" is theme-asymmetric (black 0.61 in dark, white 0.73 in light); "dim" is the darker scroll-edge scrim, black in BOTH themes (α 0.259 light / 0.853 dark over 186.7pt). Both measured; the fog only appears once thread content scrolls under the header. |
| glass | boolean | true | Master glass switch — false renders plain chrome for A/B. |
| glassStrength | number | 1.35 | Refraction multiplier on the chat-chrome caps (1 = the calibrated ceiling; iOS 26's input pill refracts a touch harder). Keep ≤ ~1.6. |
| navMaterial | NavMaterialOverrides | — | Glass-lab overrides for the nav capsules' material (tuning escape hatch). |
| className / style | string / CSSProperties | — | Passthrough to the root. The parent must give it a definite width AND height. |
GlassMessage
| Prop | Type | Default | Description |
|---|---|---|---|
| from* | "me" | "them" | — | Outgoing blue or incoming gray. |
| text | string | — | Message text (omit for audio/image messages). |
| audio | GlassAudio | — | Voice memo: { duration, waveform?, transcript?, src? } renders the iOS 26 audio bubble. With src set the bubble is a real play/pause button — see GlassAudio below. |
| image | GlassImage | — | Photo attachment: { src, width, height, alt?, save? } renders a photo bubble (aspect-fit into a square cap from the intrinsic size; a run-ending photo bleeds into the tail beak). save floats the circular save-to-photos disc — defaults to true on received photos, false on sent. Overrides text. |
| reactions | GlassReaction[] | — | Tapbacks on the bubble's top corner: heart / like / dislike or any emoji; by: "me" renders the blue capsule. |
| receipt / receiptTime | "delivered" | "read" / string | — | Receipt under the LAST sent message (1:1 only; groups show none) — "read" shows Read plus the optional time. |
| replyTo | string | number | — | Marks this message as an inline reply to that message's id. A two-message pair draws iOS's elbow connector down the gutter; chains of 3+ render the thread spine (received members break it around their avatars, sent members ride it as ring nodes). Tails land on BOTH ends and the measured reply gaps open up. Consecutive replies to the same parent from the same sender merge into one run. Unknown ids are silently ignored. |
| replies | number | — | Renders a blue "N Replies" ("1 Reply") link under this bubble — left-aligned under received, right-aligned under sent. Furniture only: it opens the measured gap and fires onReplies, but reply structure comes from replyTo on the replies. |
| sender | string | — | Group chats: which member sent this (matched by GlassGroup member name). Ignored for from: "me" and in 1:1 threads. |
| stamp | GlassStamp | — | { day, time } time divider above this message. |
| burst | boolean | — | Force a new burst — iOS splits same-sender runs that are ≳1 minute apart (new tail, fresh sender label in groups) without a time divider. |
| mentions | string[] | — | Inline mentions: every case-sensitive word-boundary occurrence of each name renders in SF Semibold, exactly as measured off device captures. |
| underline | boolean | string | string[] | — | iOS data-detector underline (dates, addresses). true underlines the whole text; a string (or list) underlines only those substrings — every case-sensitive occurrence — with the measured 1pt rule at α 0.456. |
| id | string | number | — | Stable React key, and the anchor replyTo / onReplies resolve against. |
Supporting types
| Prop | Type | Default | Description |
|---|---|---|---|
| GlassContact | { name: string; avatar: AvatarInfo; avatarDark?: AvatarInfo } | — | 1:1 peer. avatarDark supplies iOS's dimmer dark-mode monogram gradient. AvatarInfo.photoUrl renders a photo instead of the gradient monogram. |
| GlassGroup | { name?: string; members: GlassContact[] } | — | Group. Omit name for the iOS "{n} People" title. The header cluster renders up to five member avatars, arrangements measured per member-count. |
| GlassAudio | { duration: string; waveform?: number[]; transcript?: string; src?: string } | — | Voice memo payload. waveform amplitudes are 0–1, resampled to however many bars fit the bubble. src (an audio URL) makes the memo playable: tapping the bubble plays/pauses through one shared audio element (starting a memo stops any other), the waveform fills left-to-right while playing, and the duration label counts down the remaining time. Omit src for the display-only bubble — resting pixels are identical either way. |
| GlassStamp | { day: string; time: string } | — | Time divider: bold leading word(s) ("Today") + regular remainder ("10:09 AM"). |
| GlassImage | { src: string; width: number; height: number; alt?: string; save?: boolean } | — | Photo payload — intrinsic size drives the bubble's aspect ratio before load, so nothing shifts. |
| GlassReaction | { kind: "heart" | "like" | "dislike" | string; by?: "me" | "them" } | — | One tapback. kind is a built-in glyph or any emoji string; by: "me" tints the capsule iMessage blue with a white glyph. |
Accessibility
The back capsule, name pill, FaceTime button, "N Replies" links and the photo save disc are real buttons with aria-labels; the composer input is labelled "Message". A playable voice memo (GlassAudio.src) is a button too — aria-label announces "Play voice memo" with the duration, aria-pressed tracks the playing state. Decorative layers (twin canvas, wallpaper, glass, status bar) are aria-hidden.