Kai Ito
Strong on thermal design. Move her to the onsite.
A scrolling thread of messages, for an assistant or the notes on a record. The app sets its height with className. It opens at the newest message, and while the user is at the end, it keeps the newest in view as messages come in or grow. When the user scrolls up, it stays where they read, and a "Jump to latest" button takes them back. The user's own new message (align="end") always scrolls into view. Screen readers hear each new message from someone else, politely, with its author ("Sam Lee: …"); a loading one, when it is done. The messages there when the list mounts are not news (but a loading one is, when it is done), so mount it once the thread has loaded (show a Loader until then). Native <div> attributes and ref pass through.
import { type CSSProperties } from "react";
import { Message } from "@etched/monet/message";
import { MessageList } from "@etched/monet/message-list";
/** A panel with Orbit's card edge. The app sets the list's height. */
const panel = (blockSize: string): CSSProperties => ({
display: "flex",
flexDirection: "column",
blockSize,
maxInlineSize: "40rem",
borderRadius: "var(--et-card-radius)",
background: "var(--et-surface)",
boxShadow: "0 0 0 1px var(--et-line)",
});
const clock = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit" });
/** A time as the thread shows it, with the machine-readable value. */
const at = (date: Date) => <time dateTime={date.toISOString()}>{clock.format(date)}</time>;
export function Preview() {
return (
<MessageList aria-label="Notes on Ana Ruiz" style={{ ...panel("12rem"), inlineSize: "20rem" }}>
<Message name="Kai Ito" time={at(new Date("2026-10-26T09:14"))}>
Strong on thermal design. Move her to the onsite.
</Message>
<Message name="Sam Lee" time={at(new Date("2026-10-26T09:40"))}>
Agreed. I can take the systems round.
</Message>
<Message name="Mira Osei" time={at(new Date("2026-10-26T10:05"))}>
Booked for Tuesday at 10.
</Message>
</MessageList>
);
}import { MessageList } from "@etched/monet/message-list";Scroll up, then send a note: the list stays where you read, and "Jump to latest" takes you back. At the end, new notes come into view. Screen readers hear each new note with its author.
import { Fragment, useState, type CSSProperties } from "react";
import { Button } from "@etched/monet/button";
import { Marker } from "@etched/monet/marker";
import { Message } from "@etched/monet/message";
import { MessageList } from "@etched/monet/message-list";
/** A panel with Orbit's card edge. The app sets the list's height. */
const panel = (blockSize: string): CSSProperties => ({
display: "flex",
flexDirection: "column",
blockSize,
maxInlineSize: "40rem",
borderRadius: "var(--et-card-radius)",
background: "var(--et-surface)",
boxShadow: "0 0 0 1px var(--et-line)",
});
const clock = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit" });
/** A time as the thread shows it, with the machine-readable value. */
const at = (date: Date) => <time dateTime={date.toISOString()}>{clock.format(date)}</time>;
interface Note {
id: number;
name: string;
text: string;
time: Date;
}
const notes: Note[] = [
{ id: 1, name: "Kai Ito", text: "Phone screen done. Strong on RTL design, a little light on verification.", time: new Date("2026-10-25T09:10") },
{ id: 2, name: "Sam Lee", text: "I can run the verification round on Thursday.", time: new Date("2026-10-25T09:32") },
{ id: 3, name: "Mira Osei", text: "Booked Thursday at 2 PM with Sam.", time: new Date("2026-10-25T10:05") },
{ id: 4, name: "Kai Ito", text: "He asked about relocation; I sent him the guide.", time: new Date("2026-10-25T15:48") },
{ id: 5, name: "Sam Lee", text: "Verification round done. Solid on UVM, good questions about our flow.", time: new Date("2026-10-26T14:20") },
{ id: 6, name: "Mira Osei", text: "Moving him to the onsite. Who takes the systems round?", time: new Date("2026-10-26T14:45") },
];
const incoming = ["I can take the systems round.", "Feedback is in Orbit now.", "He asked for a call with the team lead."];
export function NewMessages() {
const [thread, setThread] = useState(notes);
const add = () =>
setThread((all) => [...all, { id: all.length + 1, name: "Sam Lee", text: incoming[(all.length - notes.length) % incoming.length] ?? "", time: new Date("2026-10-26T15:00") }]);
return (
<div className="rows">
<MessageList aria-label="Notes on José Núñez" style={panel("18rem")}>
<Marker>Yesterday</Marker>
{thread.map((note) => (
<Fragment key={note.id}>
{note.id === 5 && <Marker>Today</Marker>}
<Message name={note.name} time={at(note.time)}>
{note.text}
</Message>
</Fragment>
))}
</MessageList>
<div className="row">
<Button onClick={add}>Send a note from Sam Lee</Button>
</div>
</div>
);
}Messages, attachments and a composer together. Enter sends and Shift+Enter starts a new line. The answer streams in as a loading message; screen readers hear it when it is done. The composer is an InputGroupTextarea with the files to send above it.
import { ArrowUpIcon, CopyIcon, PaperclipIcon, SparkleIcon } from "@phosphor-icons/react";
import { useRef, useState, type CSSProperties, type FormEvent } from "react";
import { Attachment, AttachmentGroup } from "@etched/monet/attachment";
import { Button } from "@etched/monet/button";
import { InputGroup, InputGroupAddon, InputGroupTextarea } from "@etched/monet/input-group";
import { Loader } from "@etched/monet/loader";
import { Message } from "@etched/monet/message";
import { MessageList } from "@etched/monet/message-list";
/** A panel with Orbit's card edge. The app sets the list's height. */
const panel = (blockSize: string): CSSProperties => ({
display: "flex",
flexDirection: "column",
blockSize,
maxInlineSize: "40rem",
borderRadius: "var(--et-card-radius)",
background: "var(--et-surface)",
boxShadow: "0 0 0 1px var(--et-line)",
});
const clock = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit" });
/** A time as the thread shows it, with the machine-readable value. */
const at = (date: Date) => <time dateTime={date.toISOString()}>{clock.format(date)}</time>;
interface Turn {
id: number;
text: string;
time: Date;
/** The user's own turn. */
own?: boolean;
files?: File[];
loading?: boolean;
}
const replies = [
"Ana Ruiz has finished four of five rounds. Kai Ito and Sam Lee recommend an offer; the systems round is on Tuesday.",
"José Núñez waits on feedback from Sam Lee since October 22. I can remind Sam if you want.",
"Three offers are out: Lee Park, Priya Raman and Mira Osei. Mira accepted today.",
];
const opening: Turn[] = [
{ id: 1, own: true, text: "Who waits on feedback?", time: new Date("2026-10-26T10:02") },
{
id: 2,
text: "Two candidates wait on feedback for more than two days: Ana Ruiz (onsite on October 21, waiting on Kai Ito) and José Núñez (phone screen on October 22, waiting on Sam Lee).",
time: new Date("2026-10-26T10:02"),
},
];
const assistant = (
<span style={{ display: "grid", placeItems: "center", inlineSize: "var(--et-avatar-sm)", blockSize: "var(--et-avatar-sm)", borderRadius: "var(--et-radius-full)", background: "var(--et-contrast)", color: "var(--et-text-inverse)" }}>
<SparkleIcon aria-hidden />
</span>
);
export function AssistantThread() {
const [turns, setTurns] = useState(opening);
const [text, setText] = useState("");
const [files, setFiles] = useState<File[]>([]);
const field = useRef<HTMLTextAreaElement>(null);
const picker = useRef<HTMLInputElement>(null);
const replying = turns.at(-1)?.loading ?? false;
// A stand-in for the assistant: it thinks for a moment, then writes its answer word by word.
const answer = (id: number) => {
const words = (replies[(id / 2 - 2) % replies.length] ?? "").split(" ");
let shown = 0;
const next = () => {
shown += 1;
setTurns((all) => all.map((turn) => (turn.id === id ? { ...turn, text: words.slice(0, shown).join(" "), loading: shown < words.length } : turn)));
if (shown < words.length) setTimeout(next, 40);
};
setTimeout(next, 600);
};
const send = (event: FormEvent) => {
event.preventDefault();
if (!text.trim() || replying) return;
const id = turns.length + 1;
setTurns([...turns, { id, own: true, text, files, time: new Date() }, { id: id + 1, text: "", time: new Date(), loading: true }]);
setText("");
setFiles([]);
answer(id + 1);
};
return (
<div style={panel("30rem")}>
<MessageList aria-label="Chat with the Orbit assistant" style={{ flex: 1 }}>
{turns.map((turn) =>
turn.own ? (
<Message key={turn.id} name="You" variant="bubble" align="end" time={at(turn.time)}>
<p style={{ whiteSpace: "pre-wrap" }}>{turn.text}</p>
{turn.files?.length ? (
<AttachmentGroup aria-label="Files you sent">
{turn.files.map((file) => (
<Attachment key={file.name} name={file.name} size={file.size} type={file.type} />
))}
</AttachmentGroup>
) : null}
</Message>
) : (
<Message
key={turn.id}
name="Orbit assistant"
avatar={assistant}
time={at(turn.time)}
loading={turn.loading}
actions={!turn.loading && <Button size="sm" variant="ghost" shape="square" aria-label="Copy" icon={<CopyIcon />} onClick={() => navigator.clipboard.writeText(turn.text)} />}
>
{turn.text || <Loader size="sm" aria-label="Thinking" />}
</Message>
),
)}
</MessageList>
<form onSubmit={send} style={{ padding: "var(--et-space-3)", borderBlockStart: "1px solid var(--et-line)" }}>
<InputGroup>
{files.length > 0 && (
<InputGroupAddon align="top">
<AttachmentGroup aria-label="Files to send">
{files.map((file) => (
<Attachment
key={file.name}
name={file.name}
size={file.size}
type={file.type}
onRemove={() => {
setFiles((all) => all.filter((other) => other !== file));
// After the last file, focus goes back to the field.
if (files.length === 1) field.current?.focus();
}}
/>
))}
</AttachmentGroup>
</InputGroupAddon>
)}
<InputGroupTextarea
ref={field}
aria-label="Message"
placeholder="Ask about candidates, loops or offers"
minRows={1}
maxRows={6}
value={text}
onValueChange={setText}
onKeyDown={(event) => {
if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return;
event.preventDefault();
event.currentTarget.form?.requestSubmit();
}}
/>
<InputGroupAddon align="bottom">
<Button size="sm" variant="ghost" shape="square" aria-label="Attach files" icon={<PaperclipIcon />} onClick={() => picker.current?.click()} />
<input
ref={picker}
type="file"
multiple
hidden
onChange={(event) => {
const picked = [...(event.currentTarget.files ?? [])];
setFiles((all) => [...all, ...picked]);
event.currentTarget.value = "";
}}
/>
<Button type="submit" size="sm" variant="primary" shape="square" aria-label="Send" icon={<ArrowUpIcon />} disabled={!text.trim() || replying} />
</InputGroupAddon>
</InputGroup>
</form>
</div>
);
}ArrowUp/ArrowDown and Home/End scroll it.Message is an article named by its author. The scrolling box is a region named by aria-label (default "Messages").align="end"), and hear a loading message only when it is done.Loader until then: messages there at mount are not announced. Key each message by its id.| Prop | Type | Default | Description |
|---|---|---|---|
| children * | ReactNode | – | Messages and Markers, oldest first. Key each message by its id, so a new message is a new element. |
| aria-label | string | "Messages" | Names the scrolling region. |