Slack
A Slack channel where a question is answered by an app in its thread.
Customize
import {Scene, Video} from "odori";
import {Slack} from "../components/slack/slack";
<Video>
<Scene id="slack" duration="9s">
<Slack
workspace="Odori"
channel="support"
thinkingFrames={30}
theme="aubergine"
charactersPerSecond={34}
/>
</Scene>
</Video>Installation
pnpm odori add component slacknpx odori add component slackbunx odori add component slackEdit the source in videos/components/slack/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/slack/slack.tsx
import {Easing, Fill, interpolate, spring, typingFrames, useDesignScale, useFrame, useTyping, useVideo} from "odori";
import type {ReactNode} from "react";
export type SlackPost = {
author: string;
body: string;
time?: string;
/** Marks the sender as an app rather than a person. */
app?: boolean;
/** The avatar tile colour. */
color?: string;
/** A name mentioned at the head of the message, drawn as a link. */
mention?: string;
};
export type SlackProps = {
workspace?: string;
/** Channels in the rail. A string is public; an object can be private. */
channels?: (string | {name: string; private?: boolean})[];
directMessages?: string[];
channel?: string;
/** The message that lands and gets answered. */
ask?: SlackPost;
/** A reaction on that message. */
reaction?: {emoji: string; count: number; mine?: boolean};
/** The app's reply, typed into the thread under it. */
reply?: SlackPost;
thinkingFrames?: number;
theme?: "aubergine" | "dark";
charactersPerSecond?: number;
};
/**
* Slack's two looks, measured off the running client.
*
* The selected channel is a saturated aubergine that Slack keeps in both
* themes, not the muted one a screenshot suggests, and the sidebar's text is a
* lavender at eight tenths rather than a flat grey.
*/
/**
* The composer's icons, on Slack's own 20 grid.
*
* Drawn by eye these never landed: the set is specific (underline and a
* separate code block, no camera or microphone) and so is the weight.
*/
const COMPOSER_ICONS = {
bold:
"M4 2.75A.75.75 0 0 1 4.75 2h6.343a3.91 3.91 0 0 1 3.88 3.449A2 2 0 0 1 15 5.84l.001.067a3.9 3.9 0 0 1-1.551 3.118A4.627 4.627 0 0 1 11.875 18H4.75a.75.75 0 0 1-.75-.75V9.5a.8.8 0 0 1 .032-.218A.8.8 0 0 1 4 9.065zm2.5 5.565h3.593a2.157 2.157 0 1 0 0-4.315H6.5zm4.25 1.935H6.5v5.5h4.25a2.75 2.75 0 1 0 0-5.5",
italic:
"M7 2.75A.75.75 0 0 1 7.75 2h7.5a.75.75 0 0 1 0 1.5H12.3l-2.6 13h2.55a.75.75 0 0 1 0 1.5h-7.5a.75.75 0 0 1 0-1.5H7.7l2.6-13H7.75A.75.75 0 0 1 7 2.75",
underline:
"M17.25 17.12a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5zM14.5 1.63a.75.75 0 0 1 .75.75v8a5.25 5.25 0 1 1-10.5 0v-8a.75.75 0 0 1 1.5 0v8a3.75 3.75 0 0 0 7.5 0v-8a.75.75 0 0 1 .75-.75",
strike:
"M11.721 3.84c-.91-.334-2.028-.36-3.035-.114-1.51.407-2.379 1.861-2.164 3.15C6.718 8.051 7.939 9.5 11.5 9.5l.027.001h5.723a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5h3.66c-.76-.649-1.216-1.468-1.368-2.377-.347-2.084 1.033-4.253 3.265-4.848l.007-.002.007-.002c1.252-.307 2.68-.292 3.915.16 1.252.457 2.337 1.381 2.738 2.874a.75.75 0 0 1-1.448.39c-.25-.925-.91-1.528-1.805-1.856m2.968 9.114a.75.75 0 1 0-1.378.59c.273.64.186 1.205-.13 1.674-.333.492-.958.925-1.82 1.137-.989.243-1.991.165-3.029-.124-.93-.26-1.613-.935-1.858-1.845a.75.75 0 0 0-1.448.39c.388 1.441 1.483 2.503 2.903 2.9 1.213.338 2.486.456 3.79.135 1.14-.28 2.12-.889 2.704-1.753.6-.888.743-1.992.266-3.104",
link:
"M12.306 3.756a2.75 2.75 0 0 1 3.889 0l.05.05a2.75 2.75 0 0 1 0 3.889l-3.18 3.18a2.75 2.75 0 0 1-3.98-.095l-.03-.034a.75.75 0 0 0-1.11 1.009l.03.034a4.25 4.25 0 0 0 6.15.146l3.18-3.18a4.25 4.25 0 0 0 0-6.01l-.05-.05a4.25 4.25 0 0 0-6.01 0L9.47 4.47a.75.75 0 1 0 1.06 1.06zm-4.611 12.49a2.75 2.75 0 0 1-3.89 0l-.05-.051a2.75 2.75 0 0 1 0-3.89l3.18-3.179a2.75 2.75 0 0 1 3.98.095l.03.034a.75.75 0 1 0 1.11-1.01l-.03-.033a4.25 4.25 0 0 0-6.15-.146l-3.18 3.18a4.25 4.25 0 0 0 0 6.01l.05.05a4.25 4.25 0 0 0 6.01 0l1.775-1.775a.75.75 0 0 0-1.06-1.06z",
ordered:
"M3.792 2.094A.5.5 0 0 1 4 2.5V6h1a.5.5 0 1 1 0 1H2a.5.5 0 1 1 0-1h1V3.194l-.842.28a.5.5 0 0 1-.316-.948l1.5-.5a.5.5 0 0 1 .45.068M7.75 3.5a.75.75 0 0 0 0 1.5h10a.75.75 0 0 0 0-1.5zM7 10.75a.75.75 0 0 1 .75-.75h10a.75.75 0 0 1 0 1.5h-10a.75.75 0 0 1-.75-.75m0 6.5a.75.75 0 0 1 .75-.75h10a.75.75 0 0 1 0 1.5h-10a.75.75 0 0 1-.75-.75m-4.293-3.36a1 1 0 0 1 .793-.39c.49 0 .75.38.75.75 0 .064-.033.194-.173.409a5 5 0 0 1-.594.711c-.256.267-.552.548-.87.848l-.088.084a42 42 0 0 0-.879.845A.5.5 0 0 0 2 18h3a.5.5 0 0 0 0-1H3.242l.058-.055c.316-.298.629-.595.904-.882a6 6 0 0 0 .711-.859c.18-.277.335-.604.335-.954 0-.787-.582-1.75-1.75-1.75a2 2 0 0 0-1.81 1.147.5.5 0 1 0 .905.427 1 1 0 0 1 .112-.184",
bulleted:
"M4 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a.75.75 0 0 1 .75-.75h10a.75.75 0 0 1 0 1.5h-10A.75.75 0 0 1 7 3m.75 6.25a.75.75 0 0 0 0 1.5h10a.75.75 0 0 0 0-1.5zm0 7a.75.75 0 0 0 0 1.5h10a.75.75 0 0 0 0-1.5zM3 11a1 1 0 1 0 0-2 1 1 0 0 0 0 2m0 7a1 1 0 1 0 0-2 1 1 0 0 0 0 2",
quote:
"M3.5 2.75a.75.75 0 0 0-1.5 0v14.5a.75.75 0 0 0 1.5 0zM6.75 3a.75.75 0 0 0 0 1.5h8.5a.75.75 0 0 0 0-1.5zM6 10.25a.75.75 0 0 1 .75-.75h10.5a.75.75 0 0 1 0 1.5H6.75a.75.75 0 0 1-.75-.75m.75 5.25a.75.75 0 0 0 0 1.5h7.5a.75.75 0 0 0 0-1.5z",
code:
"M12.058 3.212c.396.12.62.54.5.936L8.87 16.29a.75.75 0 1 1-1.435-.436l3.686-12.143a.75.75 0 0 1 .936-.5M5.472 6.24a.75.75 0 0 1 .005 1.06l-2.67 2.693 2.67 2.691a.75.75 0 1 1-1.065 1.057l-3.194-3.22a.75.75 0 0 1 0-1.056l3.194-3.22a.75.75 0 0 1 1.06-.005m9.044 1.06a.75.75 0 1 1 1.065-1.056l3.194 3.221a.75.75 0 0 1 0 1.057l-3.194 3.219a.75.75 0 0 1-1.065-1.057l2.67-2.69z",
codeblock:
"M9.212 2.737a.75.75 0 1 0-1.424-.474l-2.5 7.5a.75.75 0 0 0 1.424.474zm6.038.265a.75.75 0 0 0 0 1.5h2a.25.25 0 0 1 .25.25v11.5a.25.25 0 0 1-.25.25h-13a.25.25 0 0 1-.25-.25v-3.5a.75.75 0 0 0-1.5 0v3.5c0 .966.784 1.75 1.75 1.75h13a1.75 1.75 0 0 0 1.75-1.75v-11.5a1.75 1.75 0 0 0-1.75-1.75zm-3.69.5a.75.75 0 1 0-1.12.996l1.556 1.754-1.556 1.75a.75.75 0 1 0 1.12.997l2-2.249a.75.75 0 0 0 0-.996zM3.999 9.061a.75.75 0 0 1-1.058-.062l-2-2.249a.75.75 0 0 1 0-.996l2-2.252a.75.75 0 1 1 1.12.996L2.504 6.252l1.557 1.75a.75.75 0 0 1-.062 1.059",
attach:
"M10.75 3.25a.75.75 0 0 0-1.5 0v6H3.251L3.25 10v-.75a.75.75 0 0 0 0 1.5V10v.75h6v6a.75.75 0 0 0 1.5 0v-6h6a.75.75 0 0 0 0-1.5h-6z",
format:
"M6.941 3.952c-.459-1.378-2.414-1.363-2.853.022l-4.053 12.8a.75.75 0 0 0 1.43.452l1.101-3.476h6.06l1.163 3.487a.75.75 0 1 0 1.423-.474zm1.185 8.298L5.518 4.427 3.041 12.25zm6.198-5.537a4.74 4.74 0 0 1 3.037-.081A3.74 3.74 0 0 1 20 10.208V17a.75.75 0 0 1-1.5 0v-.745a8 8 0 0 1-2.847 1.355 3 3 0 0 1-3.15-1.143C10.848 14.192 12.473 11 15.287 11H18.5v-.792c0-.984-.641-1.853-1.581-2.143a3.24 3.24 0 0 0-2.077.056l-.242.089a2.22 2.22 0 0 0-1.34 1.382l-.048.145a.75.75 0 0 1-1.423-.474l.048-.145a3.72 3.72 0 0 1 2.244-2.315zM18.5 12.5h-3.213c-1.587 0-2.504 1.801-1.57 3.085.357.491.98.717 1.572.57a6.5 6.5 0 0 0 2.47-1.223l.741-.593z",
emoji:
"M2.5 10a7.5 7.5 0 1 1 15 0 7.5 7.5 0 0 1-15 0M10 1a9 9 0 1 0 0 18 9 9 0 0 0 0-18M7.5 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3M14 8a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0m-6.385 3.766a.75.75 0 1 0-1.425.468C6.796 14.08 8.428 15 10.027 15s3.23-.92 3.838-2.766a.75.75 0 1 0-1.425-.468c-.38 1.155-1.38 1.734-2.413 1.734s-2.032-.58-2.412-1.734",
mention:
"M2.5 10a7.5 7.5 0 1 1 15 0v.645c0 1.024-.83 1.855-1.855 1.855a1.145 1.145 0 0 1-1.145-1.145V6.75a.75.75 0 0 0-1.494-.098 4.5 4.5 0 1 0 .465 6.212A2.64 2.64 0 0 0 15.646 14 3.355 3.355 0 0 0 19 10.645V10a9 9 0 1 0-3.815 7.357.75.75 0 1 0-.865-1.225A7.5 7.5 0 0 1 2.5 10m7.5 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6",
send:
"M1.5 2.106c0-.462.498-.754.901-.528l15.7 7.714a.73.73 0 0 1 .006 1.307L2.501 18.46l-.07.017a.754.754 0 0 1-.931-.733v-4.572c0-1.22.971-2.246 2.213-2.268l6.547-.17c.27-.01.75-.243.75-.797 0-.553-.5-.795-.75-.795l-6.547-.171C2.47 8.95 1.5 7.924 1.5 6.704z",
} as const;
/**
* The channel hash, taken from inside Slack's own channels mark and scaled
* to fill the grid. Typing a "#" gives the font's hash, which is upright
* where Slack's is raked, and lands a size small beside the name.
*/
const CHANNEL_HASH =
"M8.652 5.465a.751.751 0 0 1 1.478.256l-.268 1.556h1.365l.313-1.81a.75.75 0 0 1 1.478.256l-.269 1.554h1.658a.751.751 0 0 1 0 1.5h-1.915l-.475 2.753H13.8a.75.75 0 0 1 0 1.5h-2.042l-.26 1.503a.75.75 0 0 1-1.478-.255l.215-1.248H8.869l-.26 1.501a.75.75 0 0 1-1.478-.255l.215-1.246H5.593a.75.75 0 0 1 .001-1.5h2.012l.475-2.753H6.2a.75.75 0 0 1 0-1.5h2.14zM9.128 11.53h1.366l.474-2.753H9.603z";
/**
* The add-reaction control's face. Slack layers a solid disc beneath this
* one; filling both gives a blob, and this path already carries the ring,
* the plus, the eyes and the mouth.
*/
const ADD_REACTION =
"M15.5 1a.75.75 0 0 1 .75.75v2h2a.75.75 0 0 1 0 1.5h-2v2a.75.75 0 0 1-1.5 0v-2h-2a.75.75 0 0 1 0-1.5h2v-2A.75.75 0 0 1 15.5 1Zm-13 10a6.5 6.5 0 0 1 7.166-6.466.75.75 0 0 0 .152-1.493 8 8 0 1 0 7.14 7.139.75.75 0 0 0-1.492.152A6.525 6.525 0 0 1 15.5 11a6.5 6.5 0 1 1-13 0Zm4.25-.5a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Zm4.5 0a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5ZM9 15c1.277 0 2.553-.724 3.06-2.173.148-.426-.209-.827-.66-.827H6.6c-.452 0-.808.4-.66.827C6.448 14.276 7.724 15 9 15Z";
/** Slack's blue for a reaction you are part of: the ring and the ink. */
/**
* Slack's default picture is an initial on a flat ground in a rounded
* square, at a shade over half the box and in a medium rather than a bold.
*/
const DEFAULT_AVATAR = "#2B5C8A";
const REACTED_WASH = "#E3F8FF";
const REACTED_INK = "#1264A3";
/**
* The rail Slack puts left of everything: the workspace, then Home, DMs,
* Activity, Files and More, each an icon over its own label. Slack's own
* paths, on its 20 grid.
*/
const RAIL = [
{label: "Home", d: "m3 7.649-.33.223a.75.75 0 0 1-.84-1.244l7.191-4.852a1.75 1.75 0 0 1 1.958 0l7.19 4.852a.75.75 0 1 1-.838 1.244L17 7.649v7.011c0 2.071-1.679 3.84-3.75 3.84h-6.5C4.679 18.5 3 16.731 3 14.66zM11 11a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1h-1a1 1 0 0 1-1-1z"},
{label: "DMs", d: "M7.675 6.468a4.75 4.75 0 1 1 8.807 3.441.75.75 0 0 0-.067.489l.379 1.896-1.896-.38a.75.75 0 0 0-.489.068 5 5 0 0 1-.648.273.75.75 0 1 0 .478 1.422q.314-.105.611-.242l2.753.55a.75.75 0 0 0 .882-.882l-.55-2.753A6.25 6.25 0 1 0 6.23 6.064a.75.75 0 1 0 1.445.404M6.5 8.5a5 5 0 0 0-4.57 7.03l-.415 2.073a.75.75 0 0 0 .882.882l2.074-.414A5 5 0 1 0 6.5 8.5m-3.5 5a3.5 3.5 0 1 1 1.91 3.119.75.75 0 0 0-.49-.068l-1.214.243.243-1.215a.75.75 0 0 0-.068-.488A3.5 3.5 0 0 1 3 13.5"},
{label: "Activity", d: "M9.357 3.256c-.157.177-.31.504-.36 1.062l-.05.558-.55.11c-1.024.204-1.691.71-2.145 1.662-.485 1.016-.736 2.566-.752 4.857l-.002.307-.217.217-2.07 2.077c-.145.164-.193.293-.206.374a.3.3 0 0 0 .034.199c.069.12.304.321.804.321h4.665l.07.672c.034.327.17.668.4.915.214.232.536.413 1.036.413.486 0 .802-.178 1.013-.41.227-.247.362-.588.396-.916l.069-.674h4.663c.5 0 .735-.202.804-.321a.3.3 0 0 0 .034-.199c-.013-.08-.061-.21-.207-.374l-2.068-2.077-.216-.217-.002-.307c-.015-2.291-.265-3.841-.75-4.857-.455-.952-1.123-1.458-2.147-1.663l-.549-.11-.05-.557c-.052-.558-.204-.885-.36-1.062C10.503 3.1 10.31 3 10 3s-.505.1-.643.256m-1.124-.994C8.689 1.746 9.311 1.5 10 1.5s1.31.246 1.767.762c.331.374.54.85.65 1.383 1.21.369 2.104 1.136 2.686 2.357.604 1.266.859 2.989.894 5.185l1.866 1.874.012.012.011.013c.636.7.806 1.59.372 2.342-.406.705-1.223 1.072-2.103 1.072H12.77c-.128.39-.336.775-.638 1.104-.493.538-1.208.896-2.12.896-.917 0-1.638-.356-2.136-.893A3 3 0 0 1 7.23 16.5H3.843c-.88 0-1.697-.367-2.104-1.072-.433-.752-.263-1.642.373-2.342l.011-.013.012-.012 1.869-1.874c.035-2.196.29-3.919.894-5.185.582-1.22 1.475-1.988 2.684-2.357.112-.533.32-1.009.651-1.383"},
{label: "Files", d: "M4.836 3A1.836 1.836 0 0 0 3 4.836v7.328c0 .9.646 1.647 1.5 1.805V7.836A3.336 3.336 0 0 1 7.836 4.5h6.133A1.84 1.84 0 0 0 12.164 3zM1.5 12.164a3.337 3.337 0 0 0 3.015 3.32A3.337 3.337 0 0 0 7.836 18.5h3.968c.73 0 1.43-.29 1.945-.805l3.946-3.946a2.75 2.75 0 0 0 .805-1.945V7.836a3.337 3.337 0 0 0-3.015-3.32A3.337 3.337 0 0 0 12.164 1.5H4.836A3.336 3.336 0 0 0 1.5 4.836zM7.836 6A1.836 1.836 0 0 0 6 7.836v7.328C6 16.178 6.822 17 7.836 17H11.5v-4a1.5 1.5 0 0 1 1.5-1.5h4V7.836A1.836 1.836 0 0 0 15.164 6zm8.486 7H13v3.322z"},
{label: "More", d: "M14.5 10a1.75 1.75 0 1 1 3.5 0 1.75 1.75 0 0 1-3.5 0m-6.25 0a1.75 1.75 0 1 1 3.5 0 1.75 1.75 0 0 1-3.5 0M2 10a1.75 1.75 0 1 1 3.5 0A1.75 1.75 0 0 1 2 10"},
] as const;
const THEMES = {
aubergine: {
chrome: "#350D36",
sidebar: "#3F0E40",
selected: "#7D3986",
sidebarInk: "rgba(246,228,255,0.9)",
selectedInk: "#EFE1F5",
activeRow: "#F9EDFF",
activeRowInk: "#39063A",
page: "#FFFFFF",
ink: "#1D1C1D",
strong: "#1D1C1D",
muted: "#616061",
edge: "rgba(29,28,29,0.13)",
strip: "#F8F8F8",
toolIcon: "rgba(29,28,29,0.7)",
wash: "rgba(29,28,29,0.06)",
badge: "#DDDDDD",
},
dark: {
chrome: "#101214",
sidebar: "#101214",
selected: "#7D3986",
sidebarInk: "rgba(209,210,211,0.9)",
selectedInk: "#EFE1F5",
activeRow: "#1164A3",
activeRowInk: "#FFFFFF",
page: "#1A1D21",
ink: "#D1D2D3",
strong: "#F8F8F8",
muted: "#ABABAD",
edge: "#35373B",
strip: "#222529",
toolIcon: "rgba(209,210,211,0.8)",
wash: "rgba(248,248,248,0.06)",
badge: "#2C2D30",
},
};
const LINK = "#1264A3";
const SEND = "#007A5A";
/**
* Slack sets its interface in Lato, and the weights are the recognition as
* much as the colours: a username is Lato Black, the channel name beside it is
* only medium, and everything else is regular. Rendering this in the project's
* own brand sans is what made an accurate layout still read as not-quite
* Slack. Helvetica Neue is the fallback because it is the closest thing most
* machines already have; a project that wants it exact loads Lato in its brand.
*/
const FONT = '"Lato", "Helvetica Neue", Helvetica, Arial, sans-serif';
/**
* A Slack channel where an app answers in the thread.
*
* The client is drawn whole because the furniture is the recognition: the
* window bar, the aubergine rail, one channel lit and unread, and a composer
* with its formatting row and green send. The composer earns its space: a
* channel with nowhere to type reads as a screenshot of Slack, and a channel
* with one reads as somebody's Slack.
*
* The exchange keeps three beats and never overlaps them: the question lands,
* the app shows it is working, the answer types itself into the thread.
*/
export const Slack = ({
workspace = "Odori",
channels = ["general", "launches", "support", {name: "incidents", private: true}],
directMessages = [],
channel = "support",
ask = {
author: "John Doe",
mention: "Odori",
body: "the nightly export came out 40MB heavier than yesterday, same commit",
time: "4:46 PM",
},
reaction = {emoji: "👀", count: 2},
reply = {
author: "Odori",
app: true,
body: "Yesterday reused cached chunks. Today re-encoded at studio quality after the toolchain pin changed.",
time: "< 1 minute ago",
color: "#7C3AED",
},
thinkingFrames = 30,
theme = "aubergine",
charactersPerSecond = 34,
}: SlackProps) => {
const frame = useFrame();
const {fps} = useVideo();
const scale = useDesignScale();
const colors = THEMES[theme];
const px = (value: number) => value * scale;
/**
* Slack's type ladder, as ratios of its 15px body: a username is the same
* size as the message and only heavier, the channel name is a fifth larger,
* and a timestamp is four fifths. Lines are set at 22 over 15.
*/
const body = 22;
const heading = Math.round(body * 1.2);
const small = Math.round(body * 0.8);
const LINE = 1.467;
/**
* Slack's spacing, also as ratios of its 15px body: a 36px avatar, 28px
* sidebar rows indented 16px, and message rows padded 8 by 20. The client is
* tighter than it looks in a screenshot, and getting the type right while
* leaving the spacing loose is what still read as not-quite-Slack.
*/
const avatarSize = Math.round(body * 2.4);
const rowHeight = Math.round(body * 1.867);
const rowRadius = Math.round(body * 0.4);
const rowIndent = Math.round(body * 1.067);
const gutterY = Math.round(body * 0.533);
const gutterX = Math.round(body * 1.333);
/**
* The typing belongs to the person, not the app.
*
* A person composes in the box and presses send; the app answers with a
* message that is simply there. Typing the app's reply out character by
* character is the one thing none of these products actually do, and it is
* what made the earlier version read as a mock-up.
*/
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 reacted = spring({frame, fps, delayInFrames: askAt + 16, stiffness: 190, damping: 14});
const replied = spring({frame, fps, delayInFrames: replyAt, stiffness: 150, damping: 16});
const sent = frame >= askAt;
const working = frame >= workingAt && frame < replyAt;
const avatar = (post: SlackPost, size: number) => (
<span
style={{
alignItems: "center",
/* Slack's own offset: the sender's box starts two pixels above the
top of the picture, so the picture carries the nudge. */
marginTop: px(2.9),
background: post.color ?? DEFAULT_AVATAR,
borderRadius: px(size / 4.5),
color: "#FFFFFF",
display: "flex",
flex: "none",
fontSize: px(size * 0.52),
fontWeight: 500,
height: px(size),
justifyContent: "center",
width: px(size),
}}
>
{post.author.slice(0, 1).toUpperCase()}
</span>
);
/**
* One slot in the rail, so a hash, a lock, a presence dot and a section's
* own icon all land on the same left edge. Slack's rail reads as a column
* because of this, and ours read as a list of indented strings without it.
*/
const railGlyph = (node: ReactNode) => (
<span
style={{
alignItems: "center",
display: "flex",
flex: "none",
height: px(body),
justifyContent: "center",
marginRight: px(9),
width: px(body * 1.067),
}}
>
{node}
</span>
);
/** A section heading, set at the row size rather than a smaller one. */
const section = (label: string, icon: ReactNode) => (
<div
style={{
alignItems: "center",
color: colors.sidebarInk,
display: "flex",
fontSize: px(body),
fontWeight: 500,
height: px(rowHeight),
margin: `${px(12)}px ${px(10)}px ${px(2)}px`,
padding: `0 ${px(body * 0.533)}px 0 ${px(rowIndent)}px`,
}}
>
{railGlyph(icon)}
{label}
</div>
);
/** A composer button: 28 square with an 18 icon, as Slack sizes them. */
const tool = (name: keyof typeof COMPOSER_ICONS, circle = false) => (
<span
key={name}
style={{
alignItems: "center",
background: circle ? colors.wash : "transparent",
borderRadius: circle ? px(999) : px(6),
color: colors.toolIcon,
display: "flex",
flex: "none",
height: px(41),
justifyContent: "center",
width: px(41),
}}
>
<svg viewBox="0 0 20 20" width={px(26)} height={px(26)} fill="currentColor">
{/* Slack's paths carve their counters with the even-odd rule; without
it the mention icon fills in as a solid disc. */}
<path clipRule="evenodd" d={COMPOSER_ICONS[name]} fillRule="evenodd" />
</svg>
</span>
);
const glyph = (node: ReactNode, key: string) => (
<span key={key} style={{alignItems: "center", color: colors.toolIcon, display: "flex", height: px(41), justifyContent: "center", width: px(41)}}>
{node}
</span>
);
const rule = (key: string) => <span key={key} style={{background: colors.edge, height: px(26), margin: `0 ${px(6)}px`, width: px(1)}} />;
return (
<Fill style={{background: "#5B2A5E", padding: px(64)}}>
<div
style={{
borderRadius: px(12),
display: "flex",
flexDirection: "column",
fontFamily: FONT,
height: "100%",
opacity: enter,
overflow: "hidden",
transform: `translateY(${(1 - enter) * px(12)}px)`,
width: "100%",
}}
>
<div
style={{
alignItems: "center",
background: colors.chrome,
display: "flex",
flex: "none",
gap: px(10),
padding: `${px(16)}px ${px(20)}px`,
}}
>
{["#FF5F57", "#FEBC2E", "#28C840"].map((light) => (
<span key={light} style={{background: light, borderRadius: px(999), height: px(14), width: px(14)}} />
))}
</div>
<div style={{display: "flex", flex: 1, minHeight: 0}}>
{/* The rail: the workspace, then the places, each an icon over its
own label. The name is not here — it is the column's title. */}
<div style={{alignItems: "center", display: "flex", flex: "none", flexDirection: "column", paddingTop: px(12), width: px(103)}}>
<span
style={{
alignItems: "center",
background: colors.selected,
borderRadius: px(12),
color: "#FFFFFF",
display: "flex",
fontSize: px(24),
fontWeight: 700,
height: px(53),
justifyContent: "center",
marginBottom: px(14),
width: px(53),
}}
>
{workspace.slice(0, 1).toUpperCase()}
</span>
{RAIL.map((place, index) => (
<div
key={place.label}
/* Furniture. Slack's rail labels really are this small, and
growing them until they clear the readability floor draws a
Slack nobody recognises. Nothing here is content. */
data-odori-chrome
style={{
alignItems: "center",
display: "flex",
flexDirection: "column",
gap: px(5),
height: px(100),
justifyContent: "center",
width: px(76),
}}
>
<span
style={{
alignItems: "center",
background: index === 0 ? "rgba(249,237,255,0.25)" : "transparent",
borderRadius: px(12),
color: index === 0 ? "#FFFFFF" : colors.sidebarInk,
display: "flex",
height: px(53),
justifyContent: "center",
width: px(53),
}}
>
<svg viewBox="0 0 20 20" width={px(29)} height={px(29)} fill="currentColor">
<path clipRule="evenodd" d={place.d} fillRule="evenodd" />
</svg>
</span>
<span
style={{
color: index === 0 ? "#FFFFFF" : colors.sidebarInk,
fontSize: px(16),
fontWeight: 700,
lineHeight: 1.1,
}}
>
{place.label}
</span>
</div>
))}
</div>
<div style={{background: colors.sidebar, flex: "none", paddingTop: px(18), width: px(320)}}>
{/* The workspace name is the column's title, with the chevron
Slack sets beside it. */}
<div style={{alignItems: "center", display: "flex", gap: px(6), padding: `0 ${px(18)}px ${px(16)}px`}}>
<span style={{color: colors.selectedInk, fontSize: px(heading), fontWeight: 900}}>{workspace}</span>
<svg viewBox="0 0 20 20" width={px(20)} height={px(20)} fill="none" stroke={colors.selectedInk} strokeWidth={2.2}>
<path d="M6 8l4 4 4-4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
{section(
"Channels",
<svg viewBox="0 0 20 20" width={px(body * 1.067)} height={px(body * 1.067)} fill="currentColor">
<path d="M14.5 1.75a3.75 3.75 0 0 1 3.75 3.75v9a3.75 3.75 0 0 1-3.75 3.75h-9a3.75 3.75 0 0 1-3.75-3.75v-9A3.75 3.75 0 0 1 5.5 1.75zm-9 1.5A2.25 2.25 0 0 0 3.25 5.5v9a2.25 2.25 0 0 0 2.25 2.25h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25zm3.152 2.215a.751.751 0 0 1 1.478.256l-.268 1.556h1.365l.313-1.81a.75.75 0 0 1 1.478.256l-.269 1.554h1.658a.751.751 0 0 1 0 1.5h-1.915l-.475 2.753H13.8a.75.75 0 0 1 0 1.5h-2.042l-.26 1.503a.75.75 0 0 1-1.478-.255l.215-1.248H8.869l-.26 1.501a.75.75 0 0 1-1.478-.255l.215-1.246H5.593a.75.75 0 0 1 .001-1.5h2.012l.475-2.753H6.2a.75.75 0 0 1 0-1.5h2.14zm.476 6.065h1.366l.474-2.753H9.603z" />
</svg>,
)}
{channels.map((entry) => {
const name = typeof entry === "string" ? entry : entry.name;
const locked = typeof entry === "string" ? false : Boolean(entry.private);
const open = name === channel;
return (
<div
key={name}
style={{
alignItems: "center",
background: open ? colors.activeRow : "transparent",
borderRadius: px(rowRadius),
color: open ? colors.activeRowInk : colors.sidebarInk,
display: "flex",
fontSize: px(body),
/* Slack does not bolden the channel you are looking at;
the pill is what marks it. */
fontWeight: 400,
height: px(rowHeight),
margin: `${px(1)}px ${px(10)}px`,
padding: `0 ${px(body * 0.533)}px 0 ${px(rowIndent)}px`,
}}
>
{railGlyph(
locked ? (
<svg viewBox="0 0 24 24" width={px(body * 0.96)} height={px(body * 0.96)} fill="none" stroke="currentColor" strokeWidth={2}>
<rect x="5" y="10.5" width="14" height="9.5" rx="2.2" />
<path d="M8.5 10.5V7.8a3.5 3.5 0 0 1 7 0v2.7" strokeLinecap="round" />
</svg>
) : (
/* The hash Slack draws, with the strokes raked the way a
real one is. A typed "#" sits on the text baseline and
reads a size too small beside the name. */
<svg viewBox="0 0 24 24" width={px(body * 1.02)} height={px(body * 1.02)} fill="none" stroke="currentColor" strokeWidth={2.1} strokeLinecap="round">
<path d="M10 4 8 20M17 4l-2 16M4.6 9.5h15M3.4 14.5h15" />
</svg>
),
)}
{name}
</div>
);
})}
{section(
"Direct messages",
<svg viewBox="0 0 20 20" width={px(body * 1.067)} height={px(body * 1.067)} fill="currentColor">
<path clipRule="evenodd" fillRule="evenodd" d="M7.675 6.468a4.75 4.75 0 1 1 8.807 3.441.75.75 0 0 0-.067.489l.379 1.896-1.896-.38a.75.75 0 0 0-.489.068 5 5 0 0 1-.648.273.75.75 0 1 0 .478 1.422q.314-.105.611-.242l2.753.55a.75.75 0 0 0 .882-.882l-.55-2.753A6.25 6.25 0 1 0 6.23 6.064a.75.75 0 1 0 1.445.404M6.5 8.5a5 5 0 0 0-4.57 7.03l-.415 2.073a.75.75 0 0 0 .882.882l2.074-.414A5 5 0 1 0 6.5 8.5m-3.5 5a3.5 3.5 0 1 1 1.91 3.119.75.75 0 0 0-.49-.068l-1.214.243.243-1.215a.75.75 0 0 0-.068-.488A3.5 3.5 0 0 1 3 13.5" />
</svg>,
)}
{directMessages.map((name) => (
<div
key={name}
style={{
alignItems: "center",
color: colors.sidebarInk,
display: "flex",
fontSize: px(body),
height: px(rowHeight),
margin: `${px(1)}px ${px(10)}px`,
padding: `0 ${px(body * 0.533)}px 0 ${px(rowIndent)}px`,
}}
>
{railGlyph(<span style={{background: "#20A271", borderRadius: px(999), display: "block", height: px(9), width: px(9)}} />)}
{name}
</div>
))}
</div>
{/* Slack floats the conversation as its own rounded panel, with the
workspace colour showing around it. */}
<div
style={{
background: colors.page,
borderRadius: px(14),
display: "flex",
flex: 1,
flexDirection: "column",
margin: `0 ${px(10)}px ${px(10)}px 0`,
minWidth: 0,
overflow: "hidden",
}}
>
<div
style={{
borderBottom: `${px(1)}px solid ${colors.edge}`,
alignItems: "center",
color: colors.strong,
display: "flex",
flex: "none",
fontSize: px(heading),
fontWeight: 900,
gap: px(3),
padding: `${px(gutterY * 1.6)}px ${px(gutterX)}px`,
}}
>
<svg viewBox="0 0 20 20" width={px(heading)} height={px(heading)} fill="currentColor">
<g transform="translate(10 10) scale(1.55) translate(-10 -10)">
<path clipRule="evenodd" d={CHANNEL_HASH} fillRule="evenodd" />
</g>
</svg>
{channel}
</div>
<div style={{flex: 1, minHeight: 0, padding: `${px(gutterY * 2)}px ${px(gutterX)}px 0`}}>
<div style={{display: "flex", gap: px(gutterY), opacity: asked, transform: `translateY(${(1 - asked) * px(8)}px)`}}>
{avatar(ask, avatarSize)}
<div style={{minWidth: 0}}>
{/* The name's cap sits on the avatar's top edge. A loose
line-height puts half its leading above the cap, which
drops the name a few pixels and reads as misaligned. */}
<div style={{alignItems: "baseline", display: "flex", gap: px(10), lineHeight: LINE}}>
<span style={{color: colors.strong, fontSize: px(body), fontWeight: 900}}>{ask.author}</span>
<span data-odori-chrome style={{color: colors.muted, fontSize: px(small)}}>{ask.time}</span>
</div>
<div style={{color: colors.ink, fontSize: px(body), lineHeight: LINE, marginTop: px(4)}}>
{ask.mention ? <span style={{color: LINK}}>@{ask.mention} </span> : null}
{ask.body}
</div>
{reaction ? (
<span
style={{
alignItems: "center",
display: "inline-flex",
gap: px(8),
marginTop: px(12),
opacity: reacted,
transform: `scale(${0.8 + reacted * 0.2})`,
}}
>
{/* A reaction you are part of is ringed and tinted in
Slack's blue; one you are not sits on the 6% wash. */}
<span
style={{
alignItems: "center",
background: reaction.mine === false ? colors.wash : REACTED_WASH,
borderRadius: px(999),
boxShadow: reaction.mine === false ? undefined : `0 0 0 ${px(1.5)}px ${REACTED_INK}`,
display: "inline-flex",
gap: px(6),
height: px(35),
padding: `0 ${px(12)}px`,
}}
>
<span style={{fontSize: px(26)}}>{reaction.emoji}</span>
<span
data-odori-chrome
style={{
color: reaction.mine === false ? colors.strong : REACTED_INK,
fontSize: px(18),
fontWeight: 700,
}}
>
{reaction.count}
</span>
</span>
<span
style={{
alignItems: "center",
background: colors.wash,
borderRadius: px(999),
color: colors.strong,
display: "inline-flex",
height: px(35),
padding: `0 ${px(12)}px`,
}}
>
<svg viewBox="0 0 20 20" width={px(26)} height={px(26)} fill="currentColor">
<path clipRule="evenodd" d={ADD_REACTION} fillRule="evenodd" />
</svg>
</span>
</span>
) : null}
</div>
</div>
{frame >= workingAt ? (
<>
<div style={{alignItems: "center", display: "flex", gap: px(16), margin: `${px(20)}px 0 ${px(16)}px`}}>
<span data-odori-chrome style={{color: colors.muted, fontSize: px(small), fontWeight: 700}}>1 reply</span>
<span style={{background: colors.edge, flex: 1, height: px(1)}} />
</div>
<div
style={{
display: "flex",
gap: px(gutterY),
opacity: working ? 1 : replied,
transform: `translateY(${(1 - (working ? 1 : replied)) * px(8)}px)`,
}}
>
{avatar(reply, avatarSize)}
<div style={{minWidth: 0}}>
<div style={{alignItems: "center", display: "flex", gap: px(10), lineHeight: LINE}}>
<span style={{color: colors.strong, fontSize: px(body), fontWeight: 900}}>{reply.author}</span>
<span
data-odori-chrome
style={{
background: colors.badge,
borderRadius: px(4),
color: colors.muted,
fontSize: px(Math.round(body * 0.68)),
fontWeight: 700,
padding: `${px(2)}px ${px(8)}px`,
}}
>
APP
</span>
{frame >= replyAt ? <span data-odori-chrome style={{color: colors.muted, fontSize: px(small)}}>{reply.time}</span> : null}
</div>
<div style={{marginTop: px(4), minHeight: px(36)}}>
{working ? (
<span style={{alignItems: "center", display: "inline-flex", gap: px(8), height: px(32)}}>
{[0, 1, 2].map((dot) => (
<span
key={dot}
style={{
background: colors.muted,
borderRadius: px(999),
height: px(10),
opacity: 0.35 + 0.65 * Math.max(0, Math.sin(((frame - dot * 4) / fps) * Math.PI * 2)),
width: px(10),
}}
/>
))}
</span>
) : (
<span style={{color: colors.ink, fontSize: px(body), lineHeight: LINE}}>{reply.body}</span>
)}
</div>
</div>
</div>
</>
) : null}
</div>
<div
style={{
background: colors.page,
border: `${px(1)}px solid ${colors.edge}`,
borderRadius: px(12),
flex: "none",
margin: `${px(20)}px ${px(gutterX)}px ${px(gutterY * 2)}px`,
overflow: "hidden",
}}
>
{/* The formatting strip sits on its own tint, and carries the
set Slack carries: bold, italic, underline, strike, then
link and the two lists, then quote, code and code block. */}
<div
style={{
alignItems: "center",
background: colors.strip,
display: "flex",
gap: px(2),
padding: px(6),
}}
>
{(["bold", "italic", "underline", "strike"] as const).map((name) => tool(name))}
{rule("r1")}
{(["link", "ordered", "bulleted"] as const).map((name) => tool(name))}
{rule("r2")}
{(["quote", "code", "codeblock"] as const).map((name) => tool(name))}
</div>
<div
style={{
color: sent ? colors.muted : colors.ink,
fontSize: px(body),
padding: `${px(18)}px ${px(16)}px ${px(24)}px`,
}}
>
{sent ? (
`Message #${channel}`
) : (
<>
{typed.text}
{typed.caret ? (
<span
style={{
background: colors.ink,
display: "inline-block",
height: px(22),
marginLeft: px(2),
transform: `translateY(${px(4)}px)`,
width: px(2),
}}
/>
) : null}
</>
)}
</div>
<div style={{alignItems: "center", display: "flex", gap: px(2), padding: `0 ${px(10)}px ${px(10)}px`}}>
{tool("attach", true)}
{tool("format")}
{tool("emoji")}
{tool("mention")}
{glyph(
<svg viewBox="0 0 20 20" width={px(26)} height={px(26)} fill="currentColor">
<circle cx="4" cy="10" r="1.6" />
<circle cx="10" cy="10" r="1.6" />
<circle cx="16" cy="10" r="1.6" />
</svg>,
"more",
)}
{/* Send is a plain arrow until there is something to send,
and only then does it turn green. */}
<span
style={{
alignItems: "center",
background: sent ? "transparent" : SEND,
borderRadius: px(6),
display: "inline-flex",
marginLeft: "auto",
}}
>
<span style={{alignItems: "center", display: "flex", padding: `${px(8)}px ${px(12)}px`}}>
<svg viewBox="0 0 20 20" width={px(23)} height={px(23)} fill={sent ? colors.muted : "#FFFFFF"}>
<path clipRule="evenodd" d={COMPOSER_ICONS.send} fillRule="evenodd" />
</svg>
</span>
<span
style={{
background: sent ? colors.edge : "rgba(255,255,255,0.35)",
height: px(24),
width: px(1),
}}
/>
<span style={{alignItems: "center", display: "flex", padding: `${px(8)}px ${px(10)}px`}}>
<svg viewBox="0 0 20 20" width={px(17)} height={px(17)} fill="none" stroke={sent ? colors.muted : "#FFFFFF"} strokeWidth={2}>
<path d="M5 8l5 5 5-5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</span>
</div>
</div>
</div>
</div>
</div>
</Fill>
);
};
videos/components/slack/slack.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {Slack} from "./slack";
export default defineComponentPreview({
title: "Slack",
category: "Products",
description: "A Slack channel where a question is answered by an app in its thread.",
component: Slack,
canvas: {width: 1920, height: 1080, duration: "9s"},
controls: {
workspace: {type: "text", defaultValue: "Odori", maxLength: 24},
channel: {type: "text", defaultValue: "support", maxLength: 24},
thinkingFrames: {type: "number", defaultValue: 30, min: 0, max: 90, step: 2},
theme: {type: "select", defaultValue: "aubergine", options: ["aubergine", "dark"]},
charactersPerSecond: {type: "number", defaultValue: 34, min: 10, max: 80, step: 1},
},
examples: [
{name: "Aubergine", props: {}},
{name: "Dark", props: {theme: "dark"}},
{
name: "Incident",
props: {
channel: "incidents",
ask: {
author: "Jane Doe",
body: "render workers are timing out on the 4k job",
time: "2:14 AM",
color: "#E01E5A",
},
reaction: {emoji: "🚨", count: 3},
reply: {
author: "Odori",
app: true,
body: "Two workers exceeded the frame budget at 4k. Concurrency is down to 2 and the job is moving again.",
time: "< 1 minute ago",
color: "#7C3AED",
},
},
},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {Slack} from "../components/slack/slack";
<Video>
<Scene id="slack" duration="9s">
<Slack
workspace="Odori"
channel="support"
thinkingFrames={30}
theme="aubergine"
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
- messages placed, no working indicator
- Requires
- sans font
- Content limits
- workspace ≤ 24, channel ≤ 24
Props
| Prop | Type | Default | Required |
|---|---|---|---|
workspace | string | "Odori" | — |
channelsChannels in the rail. A string is public; an object can be private. | (string | {name: string; private?: boolean})[] | ["general" | — |
directMessages | string[] | [] | — |
channel | string | "support" | — |
askThe message that lands and gets answered. | SlackPost | — | — |
reactionA reaction on that message. | {emoji: string; count: number; mine?: boolean} | {emoji: "👀" | — |
replyThe app's reply, typed into the thread under it. | SlackPost | — | — |
thinkingFrames | number | 30 | — |
theme | "aubergine" | "dark" | "aubergine" | — |
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.