Microsoft Teams
A Teams channel where an app answers a post with a card.
Customize
import {Scene, Video} from "odori";
import {Teams} from "../components/teams/teams";
<Video>
<Scene id="teams" duration="9s">
<Teams
team="Engineering"
channel="Releases"
thinkingFrames={30}
theme="light"
charactersPerSecond={34}
/>
</Scene>
</Video>Installation
pnpm odori add component teamsnpx odori add component teamsbunx odori add component teamsEdit the source in videos/components/teams/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/teams/teams.tsx
import {Easing, Fill, interpolate, spring, typingFrames, useBrand, useDesignScale, useFrame, useTyping, useVideo} from "odori";
export type TeamsProps = {
/** The team the channel belongs to. */
team?: string;
/** Channels under the team. */
channels?: string[];
/** The channel on screen. */
channel?: string;
/** The post that starts the thread. */
ask?: {author: string; body: string; time?: string};
/** The app's reply. */
agent?: {name: string; body: string; time?: string};
/** The card under the reply, which is how apps answer in Teams. */
card?: {title: string; body?: string; action?: string};
thinkingFrames?: number;
theme?: "dark" | "light";
charactersPerSecond?: number;
};
/** The four places Teams keeps in its rail, each with its own glyph. */
const RAIL = [
{label: "Activity", d: "M5.5 8.5a4.5 4.5 0 0 1 9 0c0 3 1.1 4.1 1.5 4.5H4c.4-.4 1.5-1.5 1.5-4.5M8 15a2 2 0 0 0 4 0"},
{label: "Chat", d: "M3 5.5A1.5 1.5 0 0 1 4.5 4h11A1.5 1.5 0 0 1 17 5.5v7a1.5 1.5 0 0 1-1.5 1.5H8l-4 3v-3H4.5A1.5 1.5 0 0 1 3 12.5z"},
{label: "Teams", d: "M7.5 8.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5M2.5 16c0-2.6 2.2-4.2 5-4.2s5 1.6 5 4.2M13 4.4a2.5 2.5 0 0 1 0 5M14 12c2 .5 3.5 1.9 3.5 4"},
{label: "Calendar", d: "M4 5.5h12v11H4zM4 9h12M7.5 3v3M12.5 3v3"},
];
const PALETTE = {
dark: {rail: "#0A0A0A", side: "#0F0F0F", page: "#141414", card: "#1F1F1F", edge: "#2E2E2E", ink: "#FFFFFF", faint: "#ADADAD"},
light: {rail: "#EBEBEB", side: "#F5F5F5", page: "#FFFFFF", card: "#FAFAFA", edge: "#E1E1E1", ink: "#252423", faint: "#616161"},
};
const ACCENT = "#5B5FC7";
/** Teams sets its interface in the system face, Segoe UI where present. */
const FONT = '-apple-system, system-ui, "Segoe UI", "Segoe UI Web", Helvetica, Arial, sans-serif';
/**
* A Teams channel with an app answering a post.
*
* Teams is card shaped rather than line shaped: a reply of consequence
* arrives as a bordered block with a title and an action, not as a sentence
* in a stream. The app rail down the far left and the tab strip over the
* channel are the two pieces of furniture that say Teams before a word is
* read, which is why they are drawn even though nothing in the shot uses them.
*/
export const Teams = ({
team = "Engineering",
channels = ["General", "Releases", "Incidents", "Design"],
channel = "Releases",
ask = {author: "John Doe", body: "Can we get the launch cut re-rendered at web quality?", time: "9:38 AM"},
agent = {name: "Odori", body: "Re-rendered and posted. Half the size, same frames.", time: "9:41 AM"},
card = {title: "launch.mp4 · 12.4 MB", body: "1920x1080 · 30fps · web quality", action: "Open in Studio"},
thinkingFrames = 30,
theme = "light",
charactersPerSecond = 34,
}: TeamsProps) => {
const frame = useFrame();
const {fps} = useVideo();
const brand = useBrand();
const scale = useDesignScale();
const colors = PALETTE[theme];
const px = (value: number) => value * scale;
/* The typing is the person's: they compose the post, and the app answers
with a message that is simply there when it arrives. */
const typeFrom = 14;
const askAt = typeFrom + typingFrames(ask.body, {charactersPerSecond}) + 10;
const workingAt = askAt + 20;
const replyAt = workingAt + thinkingFrames;
const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});
const typed = useTyping(ask.body, {from: typeFrom, charactersPerSecond, chunk: 2});
const asked = spring({frame, fps, delayInFrames: askAt, stiffness: 150, damping: 16});
const replied = spring({frame, fps, delayInFrames: replyAt, stiffness: 150, damping: 16});
const sent = frame >= askAt;
const landed = frame >= replyAt;
const working = frame >= workingAt && frame < replyAt;
const cardIn = interpolate(landed ? frame : 0, [0, 12], [0, 1], {easing: Easing.standard});
return (
<Fill style={{background: theme === "light" ? "#DCDCDC" : "#000000", padding: px(60)}}>
<div
style={{
borderRadius: px(14),
display: "flex",
fontFamily: FONT,
height: "100%",
opacity: enter,
overflow: "hidden",
transform: `translateY(${(1 - enter) * px(12)}px)`,
width: "100%",
}}
>
{/* The app rail. */}
<div
style={{
alignItems: "center",
background: colors.rail,
display: "flex",
flex: "none",
flexDirection: "column",
gap: px(26),
padding: `${px(22)}px 0`,
width: px(88),
}}
>
{RAIL.map((place, index) => (
<div key={place.label} style={{alignItems: "center", display: "flex", flexDirection: "column", gap: px(5)}}>
<span
style={{
alignItems: "center",
background: index === 2 ? ACCENT : "transparent",
borderRadius: px(7),
color: index === 2 ? "#FFFFFF" : colors.faint,
display: "flex",
height: px(30),
justifyContent: "center",
width: px(30),
}}
>
<svg viewBox="0 0 20 20" width={px(21)} height={px(21)} fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
<path d={place.d} />
</svg>
</span>
<span style={{color: index === 2 ? colors.ink : colors.faint, fontSize: px(13)}}>{place.label}</span>
</div>
))}
</div>
{/* The teams and channels list. */}
<div style={{background: colors.side, borderRight: `${px(1)}px solid ${colors.edge}`, flex: "none", width: px(300)}}>
<div style={{color: colors.ink, fontSize: px(24), fontWeight: 600, padding: `${px(22)}px ${px(20)}px ${px(16)}px`}}>
Teams
</div>
<div style={{alignItems: "center", display: "flex", gap: px(12), padding: `${px(8)}px ${px(20)}px`}}>
<span style={{background: "#8A6ED1", borderRadius: px(8), height: px(36), width: px(36)}} />
<span style={{color: colors.ink, fontSize: px(20), fontWeight: 600}}>{team}</span>
</div>
{channels.map((name) => (
<div
key={name}
style={{
borderLeft: `${px(3)}px solid ${name === channel ? ACCENT : "transparent"}`,
color: name === channel ? colors.ink : colors.faint,
fontSize: px(19),
fontWeight: name === channel ? 600 : 400,
padding: `${px(9)}px ${px(20)}px ${px(9)}px ${px(48)}px`,
}}
>
{name}
</div>
))}
</div>
{/* The channel. */}
<div style={{background: colors.page, display: "flex", flex: 1, flexDirection: "column", minWidth: 0}}>
<div style={{borderBottom: `${px(1)}px solid ${colors.edge}`, padding: `${px(18)}px ${px(26)}px 0`}}>
<div style={{color: colors.ink, fontSize: px(25), fontWeight: 600}}>{channel}</div>
<div style={{display: "flex", gap: px(24), marginTop: px(14)}}>
{["Posts", "Files", "Notes"].map((tab, index) => (
<span
key={tab}
style={{
borderBottom: `${px(2)}px solid ${index === 0 ? ACCENT : "transparent"}`,
color: index === 0 ? colors.ink : colors.faint,
fontSize: px(19),
paddingBottom: px(10),
}}
>
{tab}
</span>
))}
</div>
</div>
<div style={{display: "flex", flex: 1, flexDirection: "column", justifyContent: "flex-end", padding: `${px(20)}px ${px(26)}px`}}>
<div
style={{
border: `${px(1)}px solid ${colors.edge}`,
borderRadius: px(8),
opacity: asked,
padding: `${px(18)}px ${px(20)}px`,
transform: `translateY(${(1 - asked) * px(8)}px)`,
}}
>
{/* The avatar sits beside the name, and everything the person said
lines up with the name rather than starting under the avatar. */}
<div style={{alignItems: "center", display: "flex", gap: px(12)}}>
<span
style={{
alignItems: "center",
background: "#8A6ED1",
borderRadius: px(999),
color: "#FFFFFF",
display: "flex",
fontSize: px(19),
height: px(42),
justifyContent: "center",
width: px(42),
}}
>
{ask.author.slice(0, 1).toUpperCase()}
</span>
<span style={{color: colors.ink, fontSize: px(21), fontWeight: 600}}>{ask.author}</span>
<span style={{color: colors.faint, fontSize: px(17)}}>{ask.time}</span>
</div>
<div
style={{
color: colors.ink,
fontSize: px(21),
lineHeight: 1.43,
marginTop: px(10),
paddingLeft: px(42 + 12),
}}
>
{ask.body}
</div>
<div
style={{
borderLeft: `${px(3)}px solid ${ACCENT}`,
marginTop: px(18),
opacity: working ? 1 : replied,
paddingLeft: px(16),
}}
>
<div style={{alignItems: "center", display: "flex", gap: px(10)}}>
<span style={{background: ACCENT, borderRadius: px(7), height: px(34), width: px(34)}} />
<span style={{color: colors.ink, fontSize: px(20), fontWeight: 600}}>{agent.name}</span>
<span
style={{
background: ACCENT,
borderRadius: px(4),
color: "#FFFFFF",
fontSize: px(13),
fontWeight: 600,
padding: `${px(1)}px ${px(7)}px`,
}}
>
APP
</span>
{landed ? <span style={{color: colors.faint, fontSize: px(17)}}>{agent.time}</span> : null}
</div>
<div style={{marginTop: px(8), minHeight: px(34), paddingLeft: px(34 + 10)}}>
{working ? (
<span style={{alignItems: "center", display: "inline-flex", gap: px(7), height: px(28)}}>
{[0, 1, 2].map((dot) => (
<span
key={dot}
style={{
background: colors.faint,
borderRadius: px(999),
height: px(9),
opacity: 0.35 + 0.65 * Math.max(0, Math.sin(((frame - dot * 4) / fps) * Math.PI * 2)),
width: px(9),
}}
/>
))}
</span>
) : (
<span style={{color: colors.ink, fontSize: px(21), lineHeight: 1.43}}>{agent.body}</span>
)}
</div>
{card && landed ? (
<div
style={{
background: colors.card,
border: `${px(1)}px solid ${colors.edge}`,
borderRadius: px(8),
marginLeft: px(34 + 10),
marginTop: px(14),
opacity: cardIn,
padding: `${px(16)}px ${px(18)}px`,
transform: `translateY(${(1 - cardIn) * px(8)}px)`,
}}
>
<div style={{color: colors.ink, fontSize: px(21), fontWeight: 600}}>{card.title}</div>
{card.body ? (
<div style={{color: colors.faint, fontSize: px(18), marginTop: px(5)}}>{card.body}</div>
) : null}
{card.action ? (
<div
style={{
border: `${px(1)}px solid ${ACCENT}`,
borderRadius: px(6),
color: ACCENT,
display: "inline-block",
fontSize: px(18),
fontWeight: 600,
marginTop: px(12),
padding: `${px(8)}px ${px(16)}px`,
}}
>
{card.action}
</div>
) : null}
</div>
) : null}
</div>
</div>
<div
style={{
border: `${px(1)}px solid ${colors.edge}`,
borderRadius: px(8),
color: sent ? colors.faint : colors.ink,
fontSize: px(19),
marginTop: px(18),
padding: `${px(14)}px ${px(18)}px`,
}}
>
{sent ? (
"Reply"
) : (
<>
{typed.text}
{typed.caret ? (
<span
style={{
background: colors.ink,
display: "inline-block",
height: px(20),
marginLeft: px(2),
transform: `translateY(${px(3)}px)`,
width: px(2),
}}
/>
) : null}
</>
)}
</div>
</div>
</div>
</div>
</Fill>
);
};
videos/components/teams/teams.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {Teams} from "./teams";
export default defineComponentPreview({
title: "Microsoft Teams",
category: "Products",
description: "A Teams channel where an app answers a post with a card.",
component: Teams,
canvas: {width: 1920, height: 1080, duration: "9s"},
controls: {
team: {type: "text", defaultValue: "Engineering", maxLength: 24},
channel: {type: "text", defaultValue: "Releases", maxLength: 24},
thinkingFrames: {type: "number", defaultValue: 30, min: 0, max: 90, step: 2},
theme: {type: "select", defaultValue: "light", options: ["light", "dark"]},
charactersPerSecond: {type: "number", defaultValue: 34, min: 10, max: 80, step: 1},
},
examples: [
{name: "Default", props: {}},
{name: "Dark", props: {theme: "dark"}},
{name: "No card", props: {card: undefined}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {Teams} from "../components/teams/teams";
<Video>
<Scene id="teams" duration="9s">
<Teams
team="Engineering"
channel="Releases"
thinkingFrames={30}
theme="light"
charactersPerSecond={34}
/>
</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
- post and card placed, no working indicator
- Requires
- sans font
- Content limits
- team ≤ 24, channel ≤ 24
Props
| Prop | Type | Default | Required |
|---|---|---|---|
teamThe team the channel belongs to. | string | "Engineering" | — |
channelsChannels under the team. | string[] | ["General" | — |
channelThe channel on screen. | string | "Releases" | — |
askThe post that starts the thread. | {author: string; body: string; time?: string} | {author: "John Doe" | — |
agentThe app's reply. | {name: string; body: string; time?: string} | {name: "Odori" | — |
cardThe card under the reply, which is how apps answer in Teams. | {title: string; body?: string; action?: string} | {title: "launch.mp4 · 12.4 MB" | — |
thinkingFrames | number | 30 | — |
theme | "dark" | "light" | "light" | — |
charactersPerSecond | number | 34 | — |
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.iMessageMessages with the conversation list beside the thread, the reply growing from the typing bubble.LinearA Linear workspace where an agent comments on an issue and moves it to done.