Charged border
A moving glow around a rounded frame.
Customize
import {Scene, Video} from "odori";
import {ChargedBorder} from "../components/charged-border/charged-border";
<Video>
<Scene id="charged-border" duration="6s">
<ChargedBorder
width={3}
radius={20}
speed={0.4}
at={60}
flareFrames={18}
/>
</Scene>
</Video>Installation
pnpm odori add component charged-bordernpx odori add component charged-borderbunx odori add component charged-borderEdit the source in videos/components/charged-border/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/charged-border/charged-border.tsx
import type {CSSProperties, ReactNode} from "react";
import {Easing, Fill, interpolate, useBrand, useDesignScale, useFrame, useVideo} from "odori";
export type ChargedBorderProps = {
/** What sits inside the frame. */
children?: ReactNode;
/** Colour of the pass. Defaults to the brand accent. */
color?: string;
/** Border thickness in design pixels. */
width?: number;
/** Corner radius in design pixels. */
radius?: number;
/** Revolutions per second. */
speed?: number;
/** Frame the flare peaks on, for landing an emphasis beat. */
at?: number;
/** Frames the flare takes to swell and again to settle. */
flareFrames?: number;
};
/**
* An energy pass circling a frame.
*
* The whole ring is one conic gradient — dim border colour most of the way
* round, a hot arc at the end — masked to the border band, so the pass follows
* the corners instead of clipping through them the way a translated highlight
* would. Its angle is a function of the frame number, never of wall time, so a
* scrub and a render agree about where the pass is.
*
* `at` is for emphasis: the ring flares around that frame and settles again,
* which lets the border land a beat instead of only idling.
*/
export const ChargedBorder = ({
children,
color,
width = 3,
radius = 20,
speed = 0.4,
at,
flareFrames = 18,
}: ChargedBorderProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const {fps} = useVideo();
const glow = color ?? brand.colors.accent;
const band = Math.max(1, width * scale);
const corner = radius * scale;
const angle = ((frame / fps) * speed * 360) % 360;
const flare =
at === undefined
? 0
: interpolate(frame, [at - flareFrames, at, at + flareFrames], [0, 1, 0], {easing: Easing.standard});
/* The conic layer, masked down to the border band: content-box knocked out
of the full box, which leaves exactly the ring the padding describes. */
const ring: CSSProperties = {
background: `conic-gradient(from ${angle}deg, ${brand.colors.border} 0deg, ${brand.colors.border} 230deg, ${glow} 320deg, ${glow} 348deg, ${brand.colors.border} 360deg)`,
borderRadius: corner,
inset: 0,
mask: "linear-gradient(#fff 0 0) content-box exclude, linear-gradient(#fff 0 0)",
padding: band,
position: "absolute",
WebkitMask: "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)",
WebkitMaskComposite: "xor",
};
return (
<Fill style={{alignItems: "center", justifyContent: "center"}}>
<div style={{borderRadius: corner, maxHeight: "100%", maxWidth: "100%", padding: band, position: "relative"}}>
{/* The same ring blurred, which is all a glow is. The flare widens and
brightens it, then hands back to the idle pass. */}
<div aria-hidden style={{...ring, filter: `blur(${band * (2 + flare * 4)}px)`, opacity: 0.5 + flare * 0.5}} />
<div aria-hidden style={ring} />
<div style={{borderRadius: Math.max(0, corner - band), overflow: "hidden", position: "relative"}}>
{children}
</div>
</div>
</Fill>
);
};
videos/components/charged-border/charged-border.preview.tsx
import {useBrand} from "odori";
import {defineComponentPreview} from "odori/preview";
import {ChargedBorder} from "./charged-border";
const Card = () => {
const brand = useBrand();
return (
<div
style={{
alignItems: "center",
background: brand.colors.surface,
color: brand.colors.foreground,
display: "flex",
fontFamily: brand.typography.sans,
fontSize: 64,
fontWeight: 600,
height: 420,
justifyContent: "center",
letterSpacing: "-0.03em",
width: 900,
}}
>
Now shipping
</div>
);
};
export default defineComponentPreview({
title: "Charged border",
category: "Motion",
description: "A moving glow around a rounded frame.",
component: ChargedBorder,
canvas: {width: 1920, height: 1080, duration: "6s"},
controls: {
width: {type: "number", defaultValue: 3, min: 1, max: 12, step: 1},
radius: {type: "number", defaultValue: 20, min: 0, max: 80, step: 2},
speed: {type: "number", defaultValue: 0.4, min: 0.05, max: 2, step: 0.05},
at: {type: "number", defaultValue: 60, min: 0, max: 170},
flareFrames: {type: "number", defaultValue: 18, min: 4, max: 60},
},
examples: [
{name: "Idle", props: {children: <Card />}},
{name: "Flare on the beat", props: {children: <Card />, at: 60}},
{name: "Thick and fast", props: {children: <Card />, width: 8, speed: 1.1, color: "#7dd3fc"}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {ChargedBorder} from "../components/charged-border/charged-border";
<Video>
<Scene id="charged-border" duration="6s">
<ChargedBorder
width={3}
radius={20}
speed={0.4}
at={60}
flareFrames={18}
/>
</Scene>
</Video>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Motion
- Recommended duration
- 180 frames · 6s
- Minimum duration
- 30 frames · 1s
- Entrance
- 0 frames
- Exit
- 0 frames
- Reduced motion
- set speed to 0 for a still ring; leave at unset so it never flares
- Requires
- Odori
- Content limits
- none
Props
| Prop | Type | Default | Required |
|---|---|---|---|
childrenWhat sits inside the frame. | ReactNode | — | — |
colorColour of the pass. Defaults to the brand accent. | string | — | — |
widthBorder thickness in design pixels. | number | 3 | — |
radiusCorner radius in design pixels. | number | 20 | — |
speedRevolutions per second. | number | 0.4 | — |
atFrame the flare peaks on, for landing an emphasis beat. | number | — | — |
flareFramesFrames the flare takes to swell and again to settle. | number | 18 | — |
Related components
All componentsCamera stageAn animated camera that moves through your scene.Cell revealTiles slide into place to reveal your content.Dissolve revealA noise threshold sweeps across the frame, so content arrives as a torn edge.Gradient fieldA looping gradient in your brand colors.Halftone revealContent revealed through expanding dots.KaleidoscopeThe picture folded into mirrored wedges, so type and interface turn into pattern.