Kaleidoscope
The picture folded into mirrored wedges, so type and interface turn into pattern.
Customize
import {Scene, Video} from "odori";
import {Kaleidoscope} from "../components/kaleidoscope/kaleidoscope";
<Video>
<Scene id="kaleidoscope" duration="6s">
<Kaleidoscope
segments={6}
turn={0}
mix={1}
/>
</Scene>
</Video>Installation
pnpm odori add component kaleidoscopenpx odori add component kaleidoscopebunx odori add component kaleidoscopeEdit the source in videos/components/kaleidoscope/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/kaleidoscope/kaleidoscope.tsx
import type {ReactNode} from "react";
import {EffectSurface, defineShaderEffect, numberUniform} from "odori/effects";
/**
* The captured picture folded into mirrored wedges.
*
* The frame is cut into `segments` wedges around a centre, and every wedge
* shows the same slice of the source, mirrored on alternating folds so the
* seams meet cleanly. Product UI and type turn into pattern; a logo becomes
* a rose window. Drive `turn` from the frame for a slow rotation, or leave
* it and let the source's own motion feed the pattern.
*
* The classic use in a cut is three to five seconds of texture between two
* literal scenes, or an opener that resolves: start folded, animate
* `mix` to 0, and the real picture assembles out of its own pattern.
*/
export type KaleidoscopeProps = {
children: ReactNode;
/** How many wedges. Even numbers meet cleanest. */
segments?: number;
/** Rotation of the fold, in turns. Animate from the frame. */
turn?: number;
/** 1 is fully folded, 0 is the untouched picture. */
mix?: number;
/** Which point of the source feeds the wedges. */
centre?: [number, number];
};
const fold = defineShaderEffect({
name: "kaleidoscope",
uniforms: {
segments: numberUniform(6),
turn: numberUniform(0),
amount: numberUniform(1),
centreX: numberUniform(0.5),
centreY: numberUniform(0.5),
},
fragmentShader: `
uniform float segments;
uniform float turn;
uniform float amount;
uniform float centreX;
uniform float centreY;
void main() {
vec2 uv = gl_FragCoord.xy / resolution;
vec2 aspect = vec2(resolution.x / resolution.y, 1.0);
vec2 centre = vec2(centreX, centreY);
vec2 offset = (uv - centre) * aspect;
float radius = length(offset);
float angle = atan(offset.y, offset.x) + turn * 6.2831853;
/* Fold the angle into one wedge, mirrored on alternate folds so the seam
is a reflection rather than a cut. */
float wedge = 6.2831853 / max(segments, 1.0);
float folded = mod(angle, wedge * 2.0);
folded = folded > wedge ? wedge * 2.0 - folded : folded;
vec2 sampleAt = centre + vec2(cos(folded), sin(folded)) * radius / aspect;
vec4 pattern = tex(clamp(sampleAt, 0.0, 1.0));
odoriColour = mix(tex(uv), pattern, clamp(amount, 0.0, 1.0));
}`,
});
export const Kaleidoscope = ({children, segments = 6, turn = 0, mix = 1, centre = [0.5, 0.5]}: KaleidoscopeProps) => (
<EffectSurface
effects={[fold({segments, turn, amount: mix, centreX: centre[0], centreY: centre[1]})]}
>
{children}
</EffectSurface>
);
videos/components/kaleidoscope/kaleidoscope.preview.tsx
import {Fill, useBrand} from "odori";
import {defineComponentPreview} from "odori/preview";
import {Kaleidoscope} from "./kaleidoscope";
const Source = () => {
const brand = useBrand();
return (
<Fill
style={{
alignItems: "center",
background: `repeating-linear-gradient(115deg, ${brand.colors.background} 0 140px, ${brand.colors.surface} 140px 220px, ${brand.colors.accent} 220px 260px)`,
color: brand.colors.foreground,
display: "flex",
fontFamily: brand.typography.display ?? brand.typography.sans,
fontSize: 240,
fontWeight: 700,
justifyContent: "flex-end",
paddingRight: 160,
}}
>
K
</Fill>
);
};
export default defineComponentPreview({
title: "Kaleidoscope",
category: "Motion/Treatments",
description: "The picture folded into mirrored wedges, so type and interface turn into pattern.",
component: Kaleidoscope,
canvas: {width: 1920, height: 1080, duration: "6s"},
controls: {
segments: {type: "number", defaultValue: 6, min: 2, max: 16, step: 2},
turn: {type: "number", defaultValue: 0, min: 0, max: 1, step: 0.01},
mix: {type: "number", defaultValue: 1, min: 0, max: 1, step: 0.05},
},
examples: [
{name: "Six wedges", props: {children: <Source />}},
{name: "Turned", props: {children: <Source />, turn: 0.07, segments: 8}},
{name: "Half resolved", props: {children: <Source />, mix: 0.5, segments: 8}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {Kaleidoscope} from "../components/kaleidoscope/kaleidoscope";
<Video>
<Scene id="kaleidoscope" duration="6s">
<Kaleidoscope
segments={6}
turn={0}
mix={1}
/>
</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
- 24 frames ยท 0.8s
- Entrance
- 0 frames
- Exit
- 0 frames
- Reduced motion
- set mix to 0 and the picture is untouched
- Requires
- Odori
- Content limits
- none
Props
| Prop | Type | Default | Required |
|---|---|---|---|
children | ReactNode | โ | Yes |
segmentsHow many wedges. Even numbers meet cleanest. | number | 6 | โ |
turnRotation of the fold, in turns. Animate from the frame. | number | 0 | โ |
mix1 is fully folded, 0 is the untouched picture. | number | 1 | โ |
centreWhich point of the source feeds the wedges. | [number, number] | [0.5 | โ |
Related components
All componentsCamera stageAn animated camera that moves through your scene.Cell revealTiles slide into place to reveal your content.Charged borderA moving glow around a rounded frame.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.