Satin fold
Moving folds of light and shadow.
Customize
import {Scene, Video} from "odori";
import {SatinFold} from "../components/satin-fold/satin-fold";
<Video>
<Scene id="satin-fold" duration="6s">
<SatinFold
ribbons={4}
amplitude={90}
speed={1}
/>
</Scene>
</Video>Installation
pnpm odori add component satin-foldnpx odori add component satin-foldbunx odori add component satin-foldEdit the source in videos/components/satin-fold/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/satin-fold/satin-fold.tsx
import {useId} from "react";
import {Fill, random, randomBetween, useBrand, useDesignScale, useFrame, useVideo} from "odori";
/**
* Stacked ribbons of fabric across the lower frame, each a filled sine wave
* flowing at its own pace.
*
* The silk read comes from disagreement: every ribbon takes its wavelength,
* amplitude, and phase from its own seed, so the crests slide past each other
* instead of marching. Each ribbon fills down to the bottom edge and fades as
* it goes, and the stack is painted back to front, so the overlaps deepen the
* colour the way layered cloth does rather than flattening into one shape.
*
* Amplitude is a design length: the same swell in a vertical or square cut.
*/
export type SatinFoldProps = {
/** How many ribbons stack, 3 to 5. */
ribbons?: number;
/** Height of the largest swell, in design pixels. */
amplitude?: number;
/** How fast the ribbons flow. */
speed?: number;
/** One colour per ribbon, cycled if short. Defaults to the brand. */
colors?: string[];
};
export const SatinFold = ({ribbons = 4, amplitude = 90, speed = 1, colors}: SatinFoldProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const {fps, width, height} = useVideo();
// useId keeps two stacks on one canvas from sharing gradients; the colons
// it decorates with are stripped because url(#...) cannot carry them.
const gradientId = useId().replace(/:/g, "silk");
const palette = colors?.length ? colors : [brand.colors.accent, brand.colors.muted, brand.colors.surface];
const count = Math.max(3, Math.min(5, Math.round(ribbons)));
const t = (frame / fps) * speed;
return (
<Fill style={{background: brand.colors.background}}>
<svg aria-hidden height={height} width={width} style={{display: "block"}}>
<defs>
{Array.from({length: count}, (_, index) => (
<linearGradient key={index} id={`${gradientId}-${index}`} x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor={palette[index % palette.length]} stopOpacity={0.35} />
<stop offset="100%" stopColor={palette[index % palette.length]} stopOpacity={0} />
</linearGradient>
))}
</defs>
{Array.from({length: count}, (_, index) => {
const along = index / (count - 1);
const baseline = height * (0.42 + along * 0.4);
const swell = amplitude * randomBetween(`ribbon-${index}-amp`, 0.6, 1) * scale;
const wavelength = width / randomBetween(`ribbon-${index}-waves`, 1.4, 2.6);
const phase = random(`ribbon-${index}-phase`) * Math.PI * 2;
const rate = randomBetween(`ribbon-${index}-rate`, 0.25, 0.5) * Math.PI * 2;
// Sampled every ~2% of the width, which is below what the eye can
// pick out of a sine once it is filled and layered.
const steps = 48;
let path = `M 0 ${height}`;
for (let step = 0; step <= steps; step += 1) {
const x = (step / steps) * width;
const y =
baseline +
Math.sin((x / wavelength) * Math.PI * 2 + phase + t * rate) * swell +
Math.sin((x / wavelength) * Math.PI * 4.6 + phase * 2 - t * rate * 0.6) * swell * 0.25;
path += ` L ${x.toFixed(1)} ${y.toFixed(1)}`;
}
path += ` L ${width} ${height} Z`;
return <path key={index} d={path} fill={`url(#${gradientId}-${index})`} />;
})}
</svg>
</Fill>
);
};
videos/components/satin-fold/satin-fold.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {SatinFold} from "./satin-fold";
export default defineComponentPreview({
title: "Satin fold",
category: "Backgrounds",
description: "Moving folds of light and shadow.",
component: SatinFold,
canvas: {width: 1920, height: 1080, duration: "6s"},
controls: {
ribbons: {type: "number", defaultValue: 4, min: 3, max: 5, step: 1},
amplitude: {type: "number", defaultValue: 90, min: 20, max: 220, step: 10},
speed: {type: "number", defaultValue: 1, min: 0, max: 3, step: 0.1},
},
examples: [
{name: "Brand", props: {}},
{name: "Ocean", props: {colors: ["#1b6d8f", "#2aa198", "#123c55"], amplitude: 120}},
{name: "Calm", props: {ribbons: 3, amplitude: 50, speed: 0.5}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {SatinFold} from "../components/satin-fold/satin-fold";
<Video>
<Scene id="satin-fold" duration="6s">
<SatinFold
ribbons={4}
amplitude={90}
speed={1}
/>
</Scene>
</Video>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Backgrounds
- Recommended duration
- 180 frames · 6s
- Minimum duration
- 30 frames · 1s
- Entrance
- 0 frames
- Exit
- 0 frames
- Reduced motion
- set speed to 0 and the ribbons hold one still curve
- Requires
- Odori
- Content limits
- none
Props
| Prop | Type | Default | Required |
|---|---|---|---|
ribbonsHow many ribbons stack, 3 to 5. | number | 4 | — |
amplitudeHeight of the largest swell, in design pixels. | number | 90 | — |
speedHow fast the ribbons flow. | number | 1 | — |
colorsOne colour per ribbon, cycled if short. Defaults to the brand. | string[] | — | — |
Related components
All componentsAurora veilFlowing curtains of colored light.Bead fieldA fixed grid of dots that swell as two drifting ripples cross them.Deep swellOcean waves beneath a sunlit horizon.Dither fieldA moving gradient with pixelated shading.Dust motesSoft particles drifting across the frame.Gel washBlurred bands of color over a dark background.