iMessage
Messages with the conversation list beside the thread, the reply growing from the typing bubble.
Customize
import {Scene, Video} from "odori";
import {Imessage} from "../components/imessage/imessage";
<Video>
<Scene id="imessage" duration="9s">
<Imessage
contact="Odori"
thinkingFrames={34}
receipt="Delivered"
theme="dark"
charactersPerSecond={32}
/>
</Scene>
</Video>Installation
pnpm odori add component imessagenpx odori add component imessagebunx odori add component imessageEdit the source in videos/components/imessage/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/imessage/imessage.tsx
import {Easing, Fill, interpolate, spring, typingFrames, useDesignScale, useFrame, useTyping, useVideo} from "odori";
import type {ReactNode} from "react";
export type IMessageBubble = {
/** "you" is the blue bubble on the right, "them" the grey one on the left. */
from: "them" | "you";
body: string;
};
export type IMessageConversation = {
name: string;
preview: string;
time?: string;
};
export type IMessageProps = {
/** The conversation list down the left. The first one is the open thread. */
conversations?: IMessageConversation[];
/** Who the open thread is with. */
contact?: string;
/** Bubbles already in the thread. */
bubbles?: IMessageBubble[];
/** The reply, after a typing bubble. */
reply?: IMessageBubble;
/** Frames the typing bubble holds. */
thinkingFrames?: number;
/** The receipt under the last bubble. */
receipt?: string;
/** The stamp Messages centres in the thread when a conversation resumes. */
stamp?: string;
theme?: "dark" | "light";
charactersPerSecond?: number;
};
/**
* Apple's system palette rather than an approximation of it: the greys are
* systemGray4 through systemGray6, and the blue is systemBlue, which shifts
* a step brighter in the dark so it holds the same weight against black.
*/
const PALETTE = {
dark: {
page: "#000000",
list: "#1C1C1E",
edge: "#38383A",
them: "#3B3B3D",
ink: "#FFFFFF",
faint: "#8E8E93",
field: "#1C1C1E",
blue: "#0A84FF",
},
light: {
page: "#FFFFFF",
list: "#F6F6F6",
edge: "#D1D1D6",
them: "#E9E9EB",
ink: "#000000",
faint: "#8E8E93",
field: "#FFFFFF",
blue: "#007AFF",
},
};
/** Messages sets its interface in the system face, SF Pro on Apple hardware. */
const FONT = '-apple-system, "SF Pro Text", "SF Pro", "Helvetica Neue", Helvetica, Arial, sans-serif';
/**
* Messages draws a contact with no photo as initials on a grey, never on a
* tinted circle. Two initials where there are two names, one otherwise.
*/
const CONTACT_GREY = "linear-gradient(#A5A5AA, #8A8A8F)";
const initials = (name: string) => {
const words = name.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return "";
if (words.length === 1) return words[0].slice(0, 1).toUpperCase();
return (words[0][0] + words[words.length - 1][0]).toUpperCase();
};
/**
* Messages, with the conversation list beside the thread.
*
* The list is what makes this a phone rather than a chat widget: it says this
* is one thread among many, that the agent sits in the same place as the
* people you text, and it gives the frame somewhere for the eye to rest while
* the thread does the moving. The typing bubble is still the point, and the
* reply grows out of exactly where the dots were.
*/
export const IMessage = ({
conversations = [
{name: "Odori", preview: "Done. out/launch-30s.mp4, same brand.", time: "now"},
{name: "John Doe", preview: "sounds good, ship it", time: "9:12 AM"},
{name: "Design", preview: "Dan: new mark is in Figma", time: "Yesterday"},
],
contact = "Odori",
bubbles = [{from: "you", body: "can you cut a 30 second version of the launch video?"}],
reply = {from: "them", body: "Done. out/launch-30s.mp4, same brand, ends on the mark."},
thinkingFrames = 34,
receipt = "Delivered",
stamp = "Today 11:31 AM",
theme = "dark",
charactersPerSecond = 32,
}: IMessageProps) => {
const frame = useFrame();
const {fps} = useVideo();
const scale = useDesignScale();
const colors = PALETTE[theme];
const px = (value: number) => value * scale;
/* The typing belongs to the person holding the phone. They compose the
last bubble in the field and send it; the reply arrives whole, the way
a message from someone else does. */
const last = bubbles[bubbles.length - 1];
const earlier = bubbles.slice(0, -1);
const first = 18;
const step = 18;
const typeFrom = first + earlier.length * step;
const typed = useTyping(last?.body ?? "", {from: typeFrom, charactersPerSecond, chunk: 2});
const sendAt = typeFrom + typingFrames(last?.body ?? "", {charactersPerSecond}) + 10;
const typingAt = sendAt + 12;
const replyAt = typingAt + thinkingFrames;
const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});
const landed = spring({frame, fps, delayInFrames: replyAt, stiffness: 180, damping: 15});
const sent = frame >= sendAt;
const typing = frame >= typingAt && frame < replyAt;
/**
* A bubble, with the tail Messages draws on it.
*
* The tail is two shapes rather than a corner radius: one in the bubble's
* own colour reaching past its edge, and one in the page colour carving
* that back into a hook. A small radius on the corner reads as a chamfer
* and never as a tail, which is what the earlier version did.
*/
const bubble = (
from: "them" | "you",
grown: number,
body: ReactNode,
key?: string,
tight = false,
) => {
const mine = from === "you";
const skin = mine ? colors.blue : colors.them;
const side = mine ? "right" : "left";
return (
<div
key={key}
style={{
alignItems: "flex-end",
display: "flex",
justifyContent: mine ? "flex-end" : "flex-start",
opacity: grown,
padding: `${px(3)}px ${px(26)}px`,
}}
>
<div
style={{
background: skin,
borderBottomLeftRadius: mine ? px(26) : px(5),
borderBottomRightRadius: mine ? px(5) : px(26),
borderRadius: px(26),
color: mine ? "#FFFFFF" : colors.ink,
fontSize: px(23),
lineHeight: 1.29,
maxWidth: "74%",
padding: tight ? `${px(14)}px ${px(16)}px` : `${px(14)}px ${px(20)}px`,
position: "relative",
transform: `scale(${0.8 + grown * 0.2})`,
transformOrigin: mine ? "100% 100%" : "0% 100%",
}}
>
{body}
{/* The tail, as one filled path rather than a coloured rectangle
carved by a second one in the page colour. The carve version
paints over whatever sits beside the bubble, which here is the
contact's picture. */}
<svg
viewBox="0 0 16 22"
width={px(21)}
height={px(29)}
style={{bottom: 0, position: "absolute", [side]: px(-11)}}
>
<path
d={mine ? "M16 22C7 22 0 15.6 0 6.5L0 22Z" : "M0 22C9 22 16 15.6 16 6.5L16 22Z"}
fill={skin}
/>
</svg>
</div>
</div>
);
};
return (
<Fill style={{background: theme === "light" ? "#DCDCDC" : "#000000", padding: px(60)}}>
<div
style={{
borderRadius: px(16),
display: "flex",
fontFamily: FONT,
height: "100%",
opacity: enter,
overflow: "hidden",
transform: `translateY(${(1 - enter) * px(12)}px)`,
width: "100%",
}}
>
{/* The conversation list. */}
<div style={{background: colors.list, borderRight: `${px(1)}px solid ${colors.edge}`, flex: "none", width: px(400)}}>
<div style={{color: colors.ink, fontSize: px(30), fontWeight: 700, letterSpacing: "-0.02em", padding: `${px(24)}px ${px(22)}px ${px(14)}px`}}>
Messages
</div>
<div
style={{
background: theme === "dark" ? "#2C2C2E" : "#E9E9EB",
borderRadius: px(10),
color: colors.faint,
fontSize: px(19),
margin: `0 ${px(18)}px ${px(14)}px`,
padding: `${px(10)}px ${px(14)}px`,
}}
>
Search
</div>
{conversations.map((item, index) => (
<div
key={item.name}
style={{
background: index === 0 ? (theme === "dark" ? "#2C2C2E" : "#DCDCE0") : "transparent",
borderRadius: px(10),
display: "flex",
gap: px(14),
margin: `${px(2)}px ${px(12)}px`,
padding: `${px(14)}px ${px(12)}px`,
}}
>
<span
style={{
alignItems: "center",
background: CONTACT_GREY,
borderRadius: px(999),
color: "#FFFFFF",
display: "flex",
flex: "none",
fontSize: px(20),
height: px(50),
justifyContent: "center",
width: px(50),
}}
>
{initials(item.name)}
</span>
<div style={{minWidth: 0, width: "100%"}}>
<div style={{alignItems: "baseline", display: "flex", gap: px(10)}}>
<span style={{color: colors.ink, fontSize: px(20), fontWeight: 600}}>{item.name}</span>
<span style={{color: colors.faint, fontSize: px(16), marginLeft: "auto"}}>{item.time}</span>
</div>
<div
style={{
color: colors.faint,
fontSize: px(18),
marginTop: px(3),
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{item.preview}
</div>
</div>
</div>
))}
</div>
{/* The thread. */}
<div style={{background: colors.page, display: "flex", flex: 1, flexDirection: "column", minWidth: 0}}>
<div
style={{
alignItems: "center",
borderBottom: `${px(1)}px solid ${colors.edge}`,
display: "flex",
flexDirection: "column",
gap: px(6),
padding: `${px(16)}px ${px(24)}px`,
}}
>
<span
style={{
alignItems: "center",
background: CONTACT_GREY,
borderRadius: px(999),
color: "#FFFFFF",
display: "flex",
fontSize: px(19),
height: px(48),
justifyContent: "center",
width: px(48),
}}
>
{initials(contact)}
</span>
<span style={{color: colors.ink, fontSize: px(20), fontWeight: 600, letterSpacing: "-0.01em"}}>{contact}</span>
</div>
<div style={{display: "flex", flex: 1, flexDirection: "column", justifyContent: "flex-end", paddingBottom: px(10)}}>
{/* Messages centres a stamp in the thread rather than putting a
time on each bubble, and the day is the part it emphasises. */}
{stamp ? (
<div
style={{
color: colors.faint,
fontSize: px(17),
padding: `${px(10)}px 0 ${px(14)}px`,
textAlign: "center",
}}
>
<span style={{color: colors.ink, fontWeight: 600}}>{stamp.split(" ")[0]}</span>{" "}
{stamp.split(" ").slice(1).join(" ")}
</div>
) : null}
{earlier.map((item, index) =>
bubble(
item.from,
spring({frame, fps, delayInFrames: first + index * step, stiffness: 180, damping: 15}),
item.body,
`${item.from}-${index}`,
),
)}
{last && sent
? bubble(
last.from,
spring({frame, fps, delayInFrames: sendAt, stiffness: 180, damping: 15}),
last.body,
"sent",
)
: null}
{typing
? bubble(
reply.from,
spring({frame, fps, delayInFrames: typingAt, stiffness: 200, damping: 14}),
<span style={{alignItems: "center", display: "flex", gap: px(7), height: px(11)}}>
{[0, 1, 2].map((dot) => (
<span
key={dot}
style={{
background: colors.faint,
borderRadius: px(999),
height: px(11),
opacity: 0.35 + 0.65 * Math.max(0, Math.sin(((frame - dot * 4) / fps) * Math.PI * 2)),
width: px(11),
}}
/>
))}
</span>,
"typing",
true,
)
: null}
{frame >= replyAt
? bubble(
reply.from,
landed,
reply.body,
)
: null}
{receipt && frame >= replyAt ? (
<div style={{color: colors.faint, fontSize: px(16), padding: `${px(4)}px ${px(30)}px 0`, textAlign: "right"}}>
{receipt}
</div>
) : null}
</div>
{/* The composer, with what Messages puts around the field: the
apps button on the left, and a send arrow that only appears
once there is something to send. */}
<div style={{alignItems: "center", display: "flex", gap: px(12), margin: `0 ${px(24)}px ${px(20)}px`}}>
<span
style={{
alignItems: "center",
background: colors.field,
borderRadius: px(999),
color: colors.faint,
display: "flex",
flex: "none",
height: px(38),
justifyContent: "center",
width: px(38),
}}
>
<svg viewBox="0 0 20 20" width={px(22)} height={px(22)} fill="currentColor">
<path d="M10.75 3.25a.75.75 0 0 0-1.5 0v6h-6a.75.75 0 0 0 0 1.5h6v6a.75.75 0 0 0 1.5 0v-6h6a.75.75 0 0 0 0-1.5h-6z" />
</svg>
</span>
<div
style={{
alignItems: "center",
color: sent ? colors.faint : colors.ink,
display: "flex",
flex: 1,
fontSize: px(20),
gap: px(10),
minWidth: 0,
padding: `${px(9)}px ${px(9)}px ${px(9)}px ${px(20)}px`,
}}
>
<span style={{flex: 1, minWidth: 0}}>
{sent ? (
"iMessage"
) : (
<>
{typed.text}
{typed.caret ? (
<span
style={{
background: colors.blue,
display: "inline-block",
height: px(22),
marginLeft: px(2),
transform: `translateY(${px(4)}px)`,
width: px(2),
}}
/>
) : null}
</>
)}
</span>
{sent ? (
/* An empty field carries the audio and emoji controls; the
send arrow replaces them only once there is something to
send, which is the swap Messages does. */
<span style={{alignItems: "center", color: colors.faint, display: "flex", flex: "none", gap: px(12)}}>
<svg viewBox="0 0 24 24" width={px(24)} height={px(24)} stroke="currentColor" strokeWidth={1.8} strokeLinecap="round">
<path d="M4 11v2M8 8v8M12 5v14M16 8v8M20 11v2" />
</svg>
<svg viewBox="0 0 24 24" width={px(26)} height={px(26)} fill="none" stroke="currentColor" strokeWidth={1.7}>
<circle cx="12" cy="12" r="9.2" />
<circle cx="9" cy="10" r="1.1" fill="currentColor" stroke="none" />
<circle cx="15" cy="10" r="1.1" fill="currentColor" stroke="none" />
<path d="M8.4 14.2a4.6 4.6 0 0 0 7.2 0" strokeLinecap="round" />
</svg>
</span>
) : (
<span
style={{
alignItems: "center",
background: colors.blue,
borderRadius: px(999),
display: "flex",
flex: "none",
height: px(30),
justifyContent: "center",
width: px(30),
}}
>
<svg viewBox="0 0 24 24" width={px(18)} height={px(18)} fill="none" stroke="#FFFFFF" strokeWidth={2.6}>
<path d="M12 19V6M6 12l6-6 6 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
)}
</div>
</div>
</div>
</div>
</Fill>
);
};
videos/components/imessage/imessage.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {IMessage} from "./imessage";
export default defineComponentPreview({
title: "iMessage",
category: "Products",
description: "Messages with the conversation list beside the thread, the reply growing from the typing bubble.",
component: IMessage,
canvas: {width: 1920, height: 1080, duration: "9s"},
controls: {
contact: {type: "text", defaultValue: "Odori", maxLength: 24},
receipt: {type: "text", defaultValue: "Delivered", maxLength: 20},
thinkingFrames: {type: "number", defaultValue: 34, min: 0, max: 90, step: 2},
theme: {type: "select", defaultValue: "dark", options: ["dark", "light"]},
charactersPerSecond: {type: "number", defaultValue: 32, min: 10, max: 80, step: 1},
},
examples: [
{name: "Default", props: {}},
{name: "Light", props: {theme: "light"}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {Imessage} from "../components/imessage/imessage";
<Video>
<Scene id="imessage" duration="9s">
<Imessage
contact="Odori"
thinkingFrames={34}
receipt="Delivered"
theme="dark"
charactersPerSecond={32}
/>
</Scene>
</Video>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Products
- Recommended duration
- 270 frames · 9s
- Minimum duration
- 150 frames · 5s
- Entrance
- 14 frames
- Exit
- 12 frames
- Reduced motion
- bubbles placed, no typing bubble
- Requires
- sans font
- Content limits
- contact ≤ 24, receipt ≤ 20
Props
| Prop | Type | Default | Required |
|---|---|---|---|
conversationsThe conversation list down the left. The first one is the open thread. | IMessageConversation[] | — | — |
contactWho the open thread is with. | string | "Odori" | — |
bubblesBubbles already in the thread. | IMessageBubble[] | [{from: "you" | — |
replyThe reply, after a typing bubble. | IMessageBubble | {from: "them" | — |
thinkingFramesFrames the typing bubble holds. | number | 34 | — |
receiptThe receipt under the last bubble. | string | "Delivered" | — |
stampThe stamp Messages centres in the thread when a conversation resumes. | string | "Today 11:31 AM" | — |
theme | "dark" | "light" | "dark" | — |
charactersPerSecond | number | 32 | — |
Related components
All componentsDiscordA Discord server where a bot answers in a channel, quoting the message it replies to.Dock barA macOS-style dock where one app launches with a bounce.GitHub commentAn agent response on a GitHub pull request.GitHub pull requestA pull request whose checks resolve one at a time and unlock the merge.LinearA Linear workspace where an agent comments on an issue and moves it to done.SlackA Slack channel where a question is answered by an app in its thread.