Orbit text
A phrase circles slowly, badge-style, around a centerpiece.
Customize
import {Scene, Video} from "odori";
import {OrbitText} from "../components/orbit-text/orbit-text";
<Video>
<Scene id="orbit-text" duration="6s">
<OrbitText
text="SHIPPED WITH CARE"
radius={240}
speed={0.25}
size={36}
/>
</Scene>
</Video>Installation
pnpm odori add component orbit-textnpx odori add component orbit-textbunx odori add component orbit-textEdit the source in videos/components/orbit-text/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/orbit-text/orbit-text.tsx
import type {ReactNode} from "react";
import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";
export type OrbitTextProps = {
text: string;
/** Ring radius in design pixels. */
radius?: number;
/** Degrees of rotation per frame. Negative runs counter-clockwise. */
speed?: number;
/** Glyph size in design pixels. */
size?: number;
/** Rendered at the center of the ring. Defaults to a small accent dot. */
children?: ReactNode;
};
/**
* A phrase set around a circle, turning slowly — a badge or seal beat. The
* text is repeated with a separator until the ring is comfortably full, then
* the glyphs are spread evenly so the circle always closes without a gap.
* Rotation is frame times speed, so the seal turns at the same rate in the
* preview and in every render chunk.
*/
export const OrbitText = ({text, radius = 240, speed = 0.25, size = 36, children}: OrbitTextProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
// Repeat a short phrase until the ring's circumference is roughly covered.
const capacity = Math.max([...text].length, Math.floor((2 * Math.PI * radius) / (size * 0.72)));
let ring = text;
while ([...ring].length + [...text].length + 3 <= capacity) {
ring += ` • ${text}`;
}
const glyphs = [...`${ring} • `];
const rotation = frame * speed;
const entrance = interpolate(frame, [0, 24], [0, 1], {easing: Easing.standard});
return (
<Fill style={{alignItems: "center", justifyContent: "center"}}>
<div style={{height: radius * 2 * scale, opacity: entrance, position: "relative", width: radius * 2 * scale}}>
{glyphs.map((glyph, index) => (
<span
key={`${glyph}-${index}`}
style={{
fontSize: size * scale,
fontWeight: 550,
left: "50%",
letterSpacing: "0.06em",
position: "absolute",
top: "50%",
transform: `translate(-50%, -50%) rotate(${(index * 360) / glyphs.length + rotation}deg) translateY(${-radius * scale}px)`,
whiteSpace: "pre",
}}
>
{glyph}
</span>
))}
<div
style={{
alignItems: "center",
display: "flex",
inset: 0,
justifyContent: "center",
position: "absolute",
}}
>
{children ?? (
<div
style={{
background: brand.colors.accent,
borderRadius: "50%",
height: 18 * scale,
width: 18 * scale,
}}
/>
)}
</div>
</div>
</Fill>
);
};
videos/components/orbit-text/orbit-text.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {OrbitText} from "./orbit-text";
export default defineComponentPreview({
title: "Orbit text",
category: "Typography/Titles",
description: "A phrase circles slowly, badge-style, around a centerpiece.",
component: OrbitText,
canvas: {width: 1080, height: 1080, duration: "6s"},
controls: {
text: {type: "text", defaultValue: "SHIPPED WITH CARE", maxLength: 32},
radius: {type: "number", defaultValue: 240, min: 100, max: 420},
speed: {type: "number", defaultValue: 0.25, min: -2, max: 2, step: 0.05},
size: {type: "number", defaultValue: 36, min: 16, max: 72},
},
examples: [
{name: "Default", props: {text: "SHIPPED WITH CARE"}},
{name: "Tight seal", props: {text: "EST. 2026", radius: 150, size: 28, speed: 0.5}},
{name: "Reverse", props: {text: "AROUND WE GO", speed: -0.4}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {OrbitText} from "../components/orbit-text/orbit-text";
<Video>
<Scene id="orbit-text" duration="6s">
<OrbitText
text="SHIPPED WITH CARE"
radius={240}
speed={0.25}
size={36}
/>
</Scene>
</Video>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Typography
- Recommended duration
- 180 frames · 6s
- Minimum duration
- 60 frames · 2s
- Entrance
- 24 frames
- Exit
- 0 frames
- Reduced motion
- the ring fades in and holds still instead of rotating
- Requires
- sans font
- Content limits
- text ≤ 32
Props
| Prop | Type | Default | Required |
|---|---|---|---|
text | string | — | Yes |
radiusRing radius in design pixels. | number | 240 | — |
speedDegrees of rotation per frame. Negative runs counter-clockwise. | number | 0.25 | — |
sizeGlyph size in design pixels. | number | 36 | — |
childrenRendered at the center of the ring. Defaults to a small accent dot. | ReactNode | — | — |
Related components
All componentsCaptionsFrame-accurate caption cues for sound-off playback.Character riseCharacters rise with a crisp per-letter stagger.Draw titleAn outline draws across the line, then fills solid.Flap boardA split-flap board clatters through to the target string.Focus inWords settle from a soft blur into a crisp line.Glitch textA title that arrives through brief seeded interference bursts.