file-tree
Progressive repository navigation with active file state.
import {Scene, Video} from "odori";
import {FileTree} from "../components/file-tree/file-tree";
<Video>
<Scene id="file-tree" duration="5s">
<FileTree
nodes={[
{
name: "layout.tsx",
kind: "file"
},
{
name: "components/",
kind: "folder"
},
{
name: "title-reveal/",
depth: 1,
kind: "folder"
},
{
name: "launch/",
kind: "folder"
},
{
name: "video.tsx",
depth: 1,
kind: "file"
},
{
name: "schema.ts",
depth: 1,
kind: "file"
}
]}
activeIndex={4}
title="videos/"
/>
</Scene>
</Video>The same runtime that renders the export. Scrub to any frame, switch format, and edit the fixture props.
Installation
pnpm odori add @odori/file-treenpx odori add @odori/file-treebunx odori add @odori/file-treeSource is copied into videos/components/file-tree/ and belongs to your repository. odori diff compares it with upstream later; odori update applies the change you accept. It has no registry dependencies.
Copy and paste the following code into your project.
videos/components/file-tree/file-tree.tsx
import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";
export type FileTreeNode = {
/** Path segment shown on the row. */
name: string;
/** Depth from the root, starting at zero. */
depth?: number;
kind?: "folder" | "file";
};
export type FileTreeProps = {
nodes: FileTreeNode[];
/** Row index that ends up highlighted. */
activeIndex?: number;
title?: string;
};
/**
* A repository revealing itself row by row, ending on one active file. The
* highlight is a filled row rather than a color change, so it survives a
* light brand as well as a dark one.
*/
export const FileTree = ({nodes, activeIndex = 0, title = "videos/"}: FileTreeProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const settle = 10 + nodes.length * 5;
return (
<Fill style={{alignItems: "center", justifyContent: "center", padding: 90 * scale}}>
<div
style={{
background: brand.colors.surface,
border: `1px solid ${brand.colors.border}`,
borderRadius: 26 * scale,
minWidth: 900 * scale,
overflow: "hidden",
padding: `${28 * scale}px ${20 * scale}px`,
}}
>
<div
style={{
color: brand.colors.muted,
fontFamily: brand.typography.mono,
fontSize: 24 * scale,
padding: `0 ${18 * scale}px ${18 * scale}px`,
}}
>
{title}
</div>
{nodes.map((node, index) => {
const delay = 10 + index * 5;
const enter = interpolate(frame, [delay, delay + 16], [0, 1], {easing: Easing.standard});
const active = index === activeIndex;
const highlight = active
? interpolate(frame, [settle, settle + 16], [0, 1], {easing: Easing.standard})
: 0;
return (
<div
key={`${node.name}-${index}`}
style={{
alignItems: "center",
background: active ? `color-mix(in srgb, ${brand.colors.accent} ${highlight * 16}%, transparent)` : "transparent",
borderRadius: 12 * scale,
display: "flex",
gap: 14 * scale,
opacity: enter,
padding: `${10 * scale}px ${18 * scale}px`,
paddingLeft: (18 + (node.depth ?? 0) * 28) * scale,
transform: `translateX(${(1 - enter) * 16 * scale}px)`,
}}
>
<span
style={{
background: node.kind === "folder" ? brand.colors.muted : "transparent",
border: `${2 * scale}px solid ${node.kind === "folder" ? brand.colors.muted : brand.colors.border}`,
borderRadius: 5 * scale,
height: 18 * scale,
opacity: 0.8,
width: 18 * scale,
}}
/>
<span
style={{
color: active ? brand.colors.foreground : brand.colors.muted,
fontFamily: brand.typography.mono,
fontSize: 30 * scale,
}}
>
{node.name}
</span>
</div>
);
})}
</div>
</Fill>
);
};
videos/components/file-tree/file-tree.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {FileTree} from "./file-tree";
export default defineComponentPreview({
title: "File tree",
category: "Developer proof",
description: "Progressive repository navigation with active file state.",
component: FileTree,
canvas: {width: 1920, height: 1080, duration: "5s"},
controls: {
title: {type: "text", defaultValue: "videos/"},
activeIndex: {type: "number", defaultValue: 4, min: 0, max: 10},
},
examples: [
{
name: "Project",
props: {
nodes: [
{name: "layout.tsx", kind: "file"},
{name: "components/", kind: "folder"},
{name: "title-reveal/", depth: 1, kind: "folder"},
{name: "launch/", kind: "folder"},
{name: "video.tsx", depth: 1, kind: "file"},
{name: "schema.ts", depth: 1, kind: "file"},
],
activeIndex: 4,
},
},
],
});
Use it in a scene.
import {Scene, Video} from "odori";
import {FileTree} from "../components/file-tree/file-tree";
<Video>
<Scene id="file-tree" duration="5s">
<FileTree
nodes={[
{
name: "layout.tsx",
kind: "file"
},
{
name: "components/",
kind: "folder"
},
{
name: "title-reveal/",
depth: 1,
kind: "folder"
},
{
name: "launch/",
kind: "folder"
},
{
name: "video.tsx",
depth: 1,
kind: "file"
},
{
name: "schema.ts",
depth: 1,
kind: "file"
}
]}
activeIndex={4}
title="videos/"
/>
</Scene>
</Video>Nothing else to install.
The component imports only from odori, so there are no dependencies to add and no import paths to rewrite.
Timing contract
Every registry component declares how it behaves in time.odori test fails a cut that violates these.
- Family
- Developer proof
- Aspect ratios
- 16:9 · 1:1
- Recommended duration
- 150 frames · 5s
- Minimum duration
- 60 frames · 2s
- Entrance
- 50 frames
- Exit
- 0 frames
- Reduced motion
- rows appear together
- Requires
- mono font
- Content limits
- nodes ≤ 10, nameLength ≤ 28
Props
| Prop | Type | Default | Required |
|---|---|---|---|
nodes | FileTreeNode[] | — | Yes |
activeIndexRow index that ends up highlighted. | number | 0 | — |
title | string | "videos/" | — |