Dock bar
A macOS-style dock where one app launches with a bounce.
Customize
import {Scene, Video} from "odori";
import {DockBar} from "../components/dock-bar/dock-bar";
<Video>
<Scene id="dock-bar" duration="5s">
<DockBar
launchIndex={2}
at={34}
magnify={1.6}
/>
</Scene>
</Video>Installation
pnpm odori add component dock-barnpx odori add component dock-barbunx odori add component dock-barEdit the source in videos/components/dock-bar/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/dock-bar/dock-bar.tsx
import type {ReactNode} from "react";
import {Easing, Fill, interpolate, useDesignScale, useFrame} from "odori";
export type DockApp = {
name: string;
/** The tile's ground. Assigned from a fixed palette when omitted. */
color?: string;
};
export type DockBarProps = {
apps?: DockApp[];
/** The tile the launch beat lands on. */
launchIndex?: number;
/** The frame the launch lands on. */
at?: number;
/** Peak scale of the launched tile. */
magnify?: number;
/** The desktop behind the dock, which gives the translucency its job. */
children?: ReactNode;
};
/**
* Tile grounds when an app does not bring its own. A fixed palette cycled by
* index, so a dock is colourful and two renders of it agree.
*/
const PALETTE = ["#5B8DEF", "#E8684A", "#34B37E", "#F2B441", "#9B6DD7", "#4AB8C4", "#E064A8", "#7A8699"];
/** Up to two initials, which is all a rounded square can carry legibly. */
const initials = (name: string) =>
name
.split(" ")
.map((word) => word[0] ?? "")
.slice(0, 2)
.join("")
.toUpperCase();
/**
* A macOS-style dock with one scripted launch.
*
* The tiles are initials on coloured rounded squares, deliberately: real app
* marks are other people's trademarks, and a generic dock should read as "an
* operating system" without borrowing anyone's identity.
*
* The launch is the classic gesture in three parts, all driven by the frame
* clock: magnification swells around the focal tile with a cosine falloff so
* neighbours ease rather than snap, the tile hops twice with the second hop
* smaller, and the running dot fades in beneath it once it lands.
*/
export const DockBar = ({
apps = [{name: "Mail"}, {name: "Notes"}, {name: "Photos"}, {name: "Music"}, {name: "Maps"}, {name: "Files"}],
launchIndex = 2,
at = 34,
magnify = 1.6,
children,
}: DockBarProps) => {
const frame = useFrame();
const scale = useDesignScale();
const base = 96 * scale;
const arrive = interpolate(frame, [0, 12], [0, 1], {easing: Easing.standard});
// The magnification swells in ahead of the beat and relaxes after it.
const focus = interpolate(frame, [at - 14, at, at + 34, at + 54], [0, 1, 1, 0], {easing: Easing.standard});
// Two decaying hops, eighteen frames each, entirely a function of the beat.
const hopTime = frame - at;
const hop =
hopTime >= 0 && hopTime < 36
? Math.abs(Math.sin((hopTime / 18) * Math.PI)) * (hopTime < 18 ? 1 : 0.4) * 52 * scale
: 0;
const dot = interpolate(frame, [at + 26, at + 36], [0, 1], {easing: Easing.standard});
return (
<Fill style={{alignItems: "center", justifyContent: "flex-end"}}>
{children}
<div
style={{
alignItems: "flex-end",
background: "rgba(255,255,255,0.13)",
backdropFilter: `blur(${28 * scale}px)`,
border: "1px solid rgba(255,255,255,0.2)",
borderRadius: 30 * scale,
display: "flex",
gap: 14 * scale,
marginBottom: 40 * scale,
opacity: arrive,
padding: `${12 * scale}px ${16 * scale}px`,
transform: `translateY(${(1 - arrive) * 140 * scale}px)`,
}}
>
{apps.map((app, index) => {
const distance = Math.abs(index - launchIndex);
// Cosine falloff over two and a half neighbours on either side.
const reach = distance >= 2.5 ? 0 : (Math.cos((distance / 2.5) * Math.PI) + 1) / 2;
const size = base * (1 + (magnify - 1) * reach * focus);
const launched = index === launchIndex;
const ground = app.color ?? PALETTE[index % PALETTE.length];
return (
<div key={app.name} style={{alignItems: "center", display: "flex", flexDirection: "column"}}>
<div
style={{
alignItems: "center",
background: `linear-gradient(180deg, rgba(255,255,255,0.32), rgba(255,255,255,0) 55%), ${ground}`,
borderRadius: size * 0.24,
boxShadow: `0 ${6 * scale}px ${18 * scale}px rgba(0,0,0,0.35)`,
color: "#ffffff",
display: "flex",
fontSize: size * 0.34,
fontWeight: 640,
height: size,
justifyContent: "center",
letterSpacing: "-0.02em",
transform: launched ? `translateY(${-hop}px)` : undefined,
width: size,
}}
>
{initials(app.name)}
</div>
<span
style={{
background: "rgba(255,255,255,0.9)",
borderRadius: 999,
height: 5 * scale,
marginTop: 5 * scale,
opacity: launched ? dot : 0,
width: 5 * scale,
}}
/>
</div>
);
})}
</div>
</Fill>
);
};
videos/components/dock-bar/dock-bar.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {DockBar} from "./dock-bar";
/** A quiet desktop behind the dock, so the translucency has something to do. */
const Desktop = () => (
<div
style={{
background: "radial-gradient(120% 90% at 30% 10%, #274060 0%, #0d1420 70%)",
inset: 0,
position: "absolute",
}}
/>
);
export default defineComponentPreview({
title: "Dock bar",
category: "Products",
description: "A macOS-style dock where one app launches with a bounce.",
component: DockBar,
canvas: {width: 1920, height: 1080, duration: "5s"},
controls: {
launchIndex: {type: "number", defaultValue: 2, min: 0, max: 7},
at: {type: "number", defaultValue: 34, min: 14, max: 100},
magnify: {type: "number", defaultValue: 1.6, min: 1.1, max: 2.2, step: 0.05},
},
examples: [
{name: "Default", props: {children: <Desktop />}},
{
name: "Workspace",
props: {
children: <Desktop />,
apps: [
{name: "Mail", color: "#4A90D9"},
{name: "Calendar", color: "#E8684A"},
{name: "Odori Studio", color: "#7C3AED"},
{name: "Terminal", color: "#2E3440"},
{name: "Figma Files", color: "#34B37E"},
],
launchIndex: 2,
at: 40,
},
},
{name: "Subtle", props: {children: <Desktop />, launchIndex: 4, magnify: 1.3, at: 50}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {DockBar} from "../components/dock-bar/dock-bar";
<Video>
<Scene id="dock-bar" duration="5s">
<DockBar
launchIndex={2}
at={34}
magnify={1.6}
/>
</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
- 150 frames · 5s
- Minimum duration
- 90 frames · 3s
- Entrance
- 12 frames
- Exit
- 0 frames
- Reduced motion
- the launched tile is shown magnified with its dot, without the bounce
- Requires
- sans font
- Content limits
- apps ≤ 8, appNameLength ≤ 16
Props
| Prop | Type | Default | Required |
|---|---|---|---|
apps | DockApp[] | [{name: "Mail"} | — |
launchIndexThe tile the launch beat lands on. | number | 2 | — |
atThe frame the launch lands on. | number | 34 | — |
magnifyPeak scale of the launched tile. | number | 1.6 | — |
childrenThe desktop behind the dock, which gives the translucency its job. | ReactNode | — | — |
Related components
All componentsDiscordA Discord server where a bot answers in a channel, quoting the message it replies to.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.SlackA Slack channel where a question is answered by an app in its thread.