ChatCard
One message in frosted glass — the cards of the landing hero's conversation. Who's speaking, over which channel and when, then what they said; the booking closes the exchange in the studio's colour. The frost behind the card is a slot, so the hero can paint its footage into it.
Playground

is a fill okay if it's been 4 weeks?
import {
ChatCard,
ChatCardHeader,
ChatCardText,
} from "@/components/shared/chat-card";
<ChatCard frost={frost}>
<ChatCardHeader from="client" name="Petra" channel="Okami link" time="08:03" />
<ChatCardText>is a fill okay if it's been 4 weeks?</ChatCardText>
</ChatCard>Speakers
A client wears their initial and says where they wrote from; Okami wears its mark. In a stack, clients lean left and Okami right — the offset is the stack's, passed in className.

is a fill okay if it's been 4 weeks?
Four weeks is right on the line — let's book a fill, and if too many have grown out we'll switch to a fresh set on the spot. Friday 10:00?
import {
ChatCard,
ChatCardHeader,
ChatCardText,
} from "@/components/shared/chat-card";
<ChatCard frost={frost} className="-translate-x-4 md:-translate-x-10">
<ChatCardHeader from="client" name="Petra" channel="Okami link" time="08:03" />
<ChatCardText>is a fill okay if it's been 4 weeks?</ChatCardText>
</ChatCard>
<ChatCard frost={frost} className="translate-x-4 md:translate-x-10">
<ChatCardHeader from="okami" name="Okami" time="08:03" />
<ChatCardText>Four weeks is right on the line — …</ChatCardText>
</ChatCard>On a call
When Okami picks up the phone, live puts a phone and its label before the time. A call reads the same as a chat: each line lands whole.

Hi, how long does balayage take — and do you have anything Friday?
import {
ChatCard,
ChatCardHeader,
ChatCardText,
} from "@/components/shared/chat-card";
<ChatCard frost={frost}>
<ChatCardHeader from="client" name="Eva" channel="Phone call" time="15:20" live="Live" />
<ChatCardText>Hi, how long does balayage take — and do you have anything Friday?</ChatCardText>
</ChatCard>Booked
The exchange closes on the booking — one line in the studio tone, ChatCardBooking in place of header and text: the check, the outcome in ink, then what was booked and when. No timestamp — the appointment's time is the only one on it. The studio card is always one line, hugging it, and ends where Okami's messages end — same corners, same right edge. It's the only studio card in a conversation.

Booked · Lash fill · Friday, 10:00
import { ChatCard, ChatCardBooking } from "@/components/shared/chat-card";
<ChatCard tone="studio" frost={frost}>
<ChatCardBooking status="Booked" service="Lash fill" when="Friday, 10:00" />
</ChatCard>A conversation
The whole exchange as the hero stacks it, held still. On the landing each card rises from the bottom edge and pushes the rest up, the oldest fading through the top.

any chance saturday morning?
Hey Marek! Saturday I've got 9:30 or 11:00 for a skin fade — which works?
11 👌
Booked. See you Saturday at 11 ✂️
Booked · Skin fade · Saturday, 11:00
import {
ChatCard,
ChatCardHeader,
ChatCardText,
} from "@/components/shared/chat-card";
{lines.map((line) => (
<ChatCard key={line.id} frost={frost} className={line.from === "okami" ? right : left}>
<ChatCardHeader
from={line.from}
name={line.from === "okami" ? "Okami" : "Marek"}
channel={line.from === "okami" ? undefined : "Instagram"}
time="23:41"
/>
<ChatCardText>{line.text}</ChatCardText>
</ChatCard>
))}
<ChatCard tone="studio" frost={frost} className={right}>
<ChatCardBooking status="Booked" service="Skin fade" when="Saturday, 11:00" />
</ChatCard>Frost
frost is a slot behind the card, overhanging it by 24px. Pass ChatCardFrost — a plain canvas — and let paintFrost(root, source) fill every one under root with what sits behind its card, graded and blurred in pixels. The hero calls it every frame from its footage; this page, from a still — so the frost here is the frost there. No CSS filter or backdrop blur: the hero's stack is masked, which cancels backdrop-filter, and desktop Safari lifts a filtered element onto its own layer that ignores the card's rounded clip.
import { ChatCard, ChatCardFrost } from "@/components/shared/chat-card";
import { paintFrost } from "@/components/shared/frost";
<ChatCard frost={<ChatCardFrost />}>…</ChatCard>
// Whoever owns the backdrop paints the frost: every frame over video,
// on load / resize / change over a still.
paintFrost(stageElement, videoOrImage);Ink
The same glass, dark: for a light surface where white frost all but vanishes — the landing hero's wash. Ink turns white inside it (ink-inverse), so the words, the header's grey and the initial's disc all turn with it; its edge dims to suit.
hi! how long does balayage take?
About three hours with Nina. Friday she's free at 9:00.
<ChatCard tone="ink">
<ChatCardHeader from="client" name="Eva" channel="Instagram" time="15:20" />
<ChatCardText>hi! how long does balayage take?</ChatCardText>
</ChatCard>API reference
ChatCard
| Prop | Type | Default | Description |
|---|---|---|---|
| tone | "frost" | "ink" | "studio" | "frost" | Frost is white message glass; ink the same glass dark, for a light surface (the landing hero's wash), ink turned white inside it; studio tints the card that confirms a booking, and fits its one line. |
| frost | ReactNode | — | What frosts the card, drawn behind its content and overhanging it by 24px. Without it the card is plain white glass. |
| className | string | — | Layout only — a stack's offset, a width. A frost card is full width up to 288px (320px from md); a leaf card hugs its one line at the right end of a column that wide, so it ends where Okami's messages end. |
ChatCardHeader
| Prop | Type | Default | Description |
|---|---|---|---|
| from | "client" | "okami" | — | Who's speaking: a client wears their initial, Okami the nav's mark — glass, in colour — both 16px. |
| name | string | — | The client's name, or Okami's. |
| channel | string | — | Where the message came in — “Instagram”, “SMS”. Shown after the name. |
| via | Channel | — | Which channel it is. Given, the channel moves to the right — its ChannelLogo and name, then · and the time; on a live call, Live says it instead. |
| time | string | — | When, at the far end, in tabular figures. |
| live | string | — | On a live call: the label beside the phone icon, e.g. “Live”. |
ChatCardBooking
| Prop | Type | Default | Description |
|---|---|---|---|
| status | string | — | The outcome, one word, in ink beside the check: “Booked”, “Moved”. |
| service | string | — | What was booked: “Lash fill”, “Balayage with Nina”. Optional: the landing hero leaves it out and says only the outcome and when (“Booked · Friday, 10:00”), so the line stays one on a phone. |
| when | string | — | When it's for: “Friday, 10:00”. |
| animate | boolean | true | Pop the check on mount (BookedBadge). False renders it settled. |
ChatCardText is the message: a <p> under the header, with everything passed through. ChatCard passes the rest to its <div>.
Guidelines
- Brand storytelling, not the product's chat: the card shows a finished line landing whole — never a typing state or a partial message.
- Always frost it over imagery; on a plain surface the glass has nothing to read against. Keep the photo warm and bright so ink text holds its contrast.
- Leaf is for the booking alone — one leaf card, closing the exchange, holding one ChatCardBooking line and nothing under it. Only the outcome is in ink; the rest keeps the header's grey.
- Keep messages short and real: a client's own lowercase and emoji, Okami's warm and specific reply. Every message carries its time — timestamps are the proof. The booking carries only the appointment's.
- Decorative where the headline carries the message, as in the hero: hide the stack from assistive tech.