Accordion
Sections opening one at a time, the outgoing one closing as the next grows.
Customize
import {Scene, Video} from "odori";
import {Accordion} from "../components/accordion/accordion";
<Video>
<Scene id="accordion" duration="6s">
<Accordion
opens={0}
thenOpens={1}
thenAt={90}
/>
</Scene>
</Video>Installation
pnpm odori add component accordionnpx odori add component accordionbunx odori add component accordionEdit the source in videos/components/accordion/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/accordion/accordion.tsx
import {Easing, Fill, interpolate, useBrand, useDesignScale, useFrame} from "odori";
export type AccordionSection = {title: string; body: string};
export type AccordionProps = {
sections?: AccordionSection[];
/** Which section opens, zero based. */
opens?: number;
/** Frame it starts opening. */
openAt?: number;
/** A second section to open, which closes the first. */
thenOpens?: number;
thenAt?: number;
};
/**
* Sections opening, one at a time.
*
* Only one section is open at once and the outgoing one closes as the incoming
* one grows, so the block's height barely moves. An accordion where two
* sections are mid-animation in opposite directions is the readable version;
* one where everything jumps to its final height is a cut.
*/
export const Accordion = ({
sections = [
{title: "Is the preview the same as the export?", body: "Yes. Both render the same component at the same frame through the same runtime."},
{title: "Where do components come from?", body: "The registry, as source. odori add component copies the directory into your project and you own it."},
{title: "Can I use my own fonts?", body: "Declare them on the brand. The runtime loads them before the first frame is drawn."},
],
opens = 0,
openAt = 20,
thenOpens = 1,
thenAt = 90,
}: AccordionProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const px = (value: number) => value * scale;
const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});
const openness = (index: number) => {
const first = index === opens ? interpolate(frame, [openAt, openAt + 18], [0, 1], {easing: Easing.standard}) : 0;
const closing = index === opens && thenOpens >= 0 ? interpolate(frame, [thenAt, thenAt + 18], [0, 1], {easing: Easing.standard}) : 0;
const second = index === thenOpens ? interpolate(frame, [thenAt, thenAt + 18], [0, 1], {easing: Easing.standard}) : 0;
return Math.max(0, Math.max(first - closing, second));
};
return (
<Fill style={{alignItems: "center", background: brand.colors.background, justifyContent: "center", padding: px(120)}}>
<div style={{fontFamily: brand.typography.sans, maxWidth: px(1020), opacity: enter, width: "100%"}}>
{sections.map((section, index) => {
const open = openness(index);
return (
<div key={section.title} style={{borderBottom: `${px(1)}px solid ${brand.colors.border}`}}>
<div
style={{
alignItems: "center",
color: open > 0.4 ? "#FAFAFA" : "#C8C8CE",
display: "flex",
fontSize: px(27),
gap: px(18),
justifyContent: "space-between",
padding: `${px(26)}px 0`,
}}
>
{section.title}
<span
style={{
color: brand.colors.muted,
display: "inline-block",
fontSize: px(24),
transform: `rotate(${open * 45}deg)`,
}}
>
+
</span>
</div>
<div
style={{
color: brand.colors.muted,
fontSize: px(22),
lineHeight: 1.6,
maxHeight: px(open * 120),
opacity: open,
overflow: "hidden",
paddingBottom: px(open * 26),
}}
>
{section.body}
</div>
</div>
);
})}
</div>
</Fill>
);
};
videos/components/accordion/accordion.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {Accordion} from "./accordion";
export default defineComponentPreview({
title: "Accordion",
category: "Interface/Controls",
description: "Sections opening one at a time, the outgoing one closing as the next grows.",
component: Accordion,
canvas: {width: 1920, height: 1080, duration: "6s"},
controls: {
opens: {type: "number", defaultValue: 0, min: 0, max: 4, step: 1},
thenOpens: {type: "number", defaultValue: 1, min: -1, max: 4, step: 1},
thenAt: {type: "number", defaultValue: 90, min: 40, max: 200, step: 5},
},
examples: [
{name: "Two beats", props: {}},
{name: "One section", props: {thenOpens: -1}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {Accordion} from "../components/accordion/accordion";
<Video>
<Scene id="accordion" duration="6s">
<Accordion
opens={0}
thenOpens={1}
thenAt={90}
/>
</Scene>
</Video>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Interface
- Recommended duration
- 180 frames · 6s
- Minimum duration
- 75 frames · 2.5s
- Entrance
- 14 frames
- Exit
- 10 frames
- Reduced motion
- one section shown open
- Requires
- sans font
- Content limits
- none
Props
| Prop | Type | Default | Required |
|---|---|---|---|
sections | AccordionSection[] | — | — |
opensWhich section opens, zero based. | number | 0 | — |
openAtFrame it starts opening. | number | 20 | — |
thenOpensA second section to open, which closes the first. | number | 1 | — |
thenAt | number | 90 | — |
Related components
All componentsBrowser demoBrowser chrome for deterministic product interface frames.Card deckStacked cards that cycle from front to back.ComboboxA search field that filters a dropdown list.Command menuKeyboard-first action search and selection flow.Context menuThe menu that appears where you clicked, anchored to its own corner.Control stageAn enlarged control with an animated press.