logo-orbit
Product identity surrounded by connected integrations.
import {Scene, Video} from "odori";
import {LogoOrbit} from "../components/logo-orbit/logo-orbit";
<Video>
<Scene id="logo-orbit" duration="5s">
<LogoOrbit
center="odori"
around={[
"Next.js",
"React",
"Studio",
"FFmpeg",
"CI",
"Slack"
]}
detail="one ecosystem"
/>
</Scene>
</Video>The same runtime that renders the export. Scrub to any frame, switch format, and edit the fixture props.
Installation
pnpm odori add @odori/logo-orbitnpx odori add @odori/logo-orbitbunx odori add @odori/logo-orbitSource is copied into videos/components/logo-orbit/ and belongs to your repository. odori diff compares it with upstream later; odori update applies the change you accept. It has no registry dependencies.
Copy and paste the following code into your project.
videos/components/logo-orbit/logo-orbit.tsx
import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";
export type LogoOrbitProps = {
/** Center wordmark. Defaults to the brand name. */
center?: string;
/** Integration names placed evenly around the ring. */
around: string[];
detail?: string;
};
/**
* One identity surrounded by what connects to it. The ring is static and the
* marks arrive in order, because a spinning orbit is unreadable at any
* distance a viewer will actually watch from.
*/
export const LogoOrbit = ({center, around, detail}: LogoOrbitProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const name = center ?? brand.name;
const ring = interpolate(frame, [6, 40], [0, 1], {easing: Easing.standard});
const radius = 300 * scale;
return (
<Fill style={{alignItems: "center", justifyContent: "center"}}>
<div style={{height: radius * 2, position: "relative", width: radius * 2}}>
<div
style={{
border: `1px solid ${brand.colors.border}`,
borderRadius: 999,
inset: 0,
opacity: ring,
position: "absolute",
transform: `scale(${0.9 + ring * 0.1})`,
}}
/>
<div
style={{
alignItems: "center",
display: "flex",
flexDirection: "column",
gap: 10 * scale,
inset: 0,
justifyContent: "center",
position: "absolute",
}}
>
<span style={{fontSize: 72 * scale, fontWeight: 600, letterSpacing: "-0.05em", opacity: ring}}>{name}</span>
{detail ? <span style={{color: brand.colors.muted, fontSize: 26 * scale, opacity: ring}}>{detail}</span> : null}
</div>
{around.map((label, index) => {
const angle = (index / around.length) * Math.PI * 2 - Math.PI / 2;
const at = 20 + index * 8;
const enter = interpolate(frame, [at, at + 18], [0, 1], {easing: Easing.standard});
return (
<div
key={label}
style={{
background: brand.colors.surface,
border: `1px solid ${brand.colors.border}`,
borderRadius: 999,
fontFamily: brand.typography.mono,
fontSize: 24 * scale,
left: radius + Math.cos(angle) * radius,
opacity: enter,
padding: `${12 * scale}px ${20 * scale}px`,
position: "absolute",
top: radius + Math.sin(angle) * radius,
transform: `translate(-50%, -50%) scale(${0.85 + enter * 0.15})`,
}}
>
{label}
</div>
);
})}
</div>
</Fill>
);
};
videos/components/logo-orbit/logo-orbit.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {LogoOrbit} from "./logo-orbit";
export default defineComponentPreview({
title: "Logo orbit",
category: "Narrative",
description: "Product identity surrounded by connected integrations.",
component: LogoOrbit,
canvas: {width: 1920, height: 1080, duration: "5s"},
controls: {
center: {type: "text", defaultValue: "odori", maxLength: 18},
detail: {type: "text", defaultValue: "one ecosystem"},
},
examples: [
{name: "Ecosystem", props: {around: ["Next.js", "React", "Studio", "FFmpeg", "CI", "Slack"]}},
],
});
Use it in a scene.
import {Scene, Video} from "odori";
import {LogoOrbit} from "../components/logo-orbit/logo-orbit";
<Video>
<Scene id="logo-orbit" duration="5s">
<LogoOrbit
center="odori"
around={[
"Next.js",
"React",
"Studio",
"FFmpeg",
"CI",
"Slack"
]}
detail="one ecosystem"
/>
</Scene>
</Video>Nothing else to install.
The component imports only from odori, so there are no dependencies to add and no import paths to rewrite.
Timing contract
Every registry component declares how it behaves in time.odori test fails a cut that violates these.
- Family
- Narrative
- Aspect ratios
- 16:9 · 9:16 · 1:1
- Recommended duration
- 150 frames · 5s
- Minimum duration
- 75 frames · 2.5s
- Entrance
- 68 frames
- Exit
- 0 frames
- Reduced motion
- marks appear without scaling
- Requires
- sans font, mono font
- Content limits
- around ≤ 8, labelLength ≤ 16
Props
| Prop | Type | Default | Required |
|---|---|---|---|
centerCenter wordmark. Defaults to the brand name. | string | — | — |
aroundIntegration names placed evenly around the ring. | string[] | — | Yes |
detail | string | — | — |