File tree
Progressive repository navigation with active file state.
Customize
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>Installation
pnpm odori add component file-treenpx odori add component file-treebunx odori add component file-treeEdit the source in videos/components/file-tree/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files 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/Code",
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,
},
},
],
});
Add it to 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>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Developer proof
- 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/" | — |
Related components
All componentsProduct launchA framed source excerpt that reveals line by line.Code walkthroughProgressive code reveal with authored focal regions.Dependency graphPackages and modules connected as a readable graph.Diff proofBefore and after source changes with semantic emphasis.Log streamPaced runtime logs with levels and bounded scrolling.TerminalA typed command session with deterministic output reveals.