Icon set
A stroked icon set that draws itself on, as editable SVG source.
Customize
import {Scene, Video} from "odori";
import {IconSet} from "../components/icon-set/icon-set";
<Video>
<Scene id="icon-set" duration="6s">
<IconSet
size={44}
drawFrames={18}
/>
</Scene>
</Video>Installation
pnpm odori add component icon-setnpx odori add component icon-setbunx odori add component icon-setEdit the source in videos/components/icon-set/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/icon-set/icon-set.tsx
import {Easing, Fill, interpolate, useBrand, useDesignScale, useFrame} from "odori";
import type {CSSProperties} from "react";
/**
* A stroked icon set, drawn on a 24 grid.
*
* These are our own geometry rather than someone else's set, for the same
* reason marks-starter ships placeholder marks: a registry that vendored
* another project's icons would be making a licensing decision on your
* behalf. Every glyph is built from the same rules, so they sit together:
* a 24 box, a 2 stroke, round caps and joins, and no fills.
*
* Because they are strokes rather than fills, they can draw themselves on
* from the frame clock, which is the one thing an icon font cannot do.
*/
export const ICONS = {
check: "M4 12.5 9.5 18 20 7",
close: "M6 6 18 18M18 6 6 18",
plus: "M12 5v14M5 12h14",
minus: "M5 12h14",
"arrow-right": "M4 12h15M13 6l6 6-6 6",
"arrow-up": "M12 20V5M6 11l6-6 6 6",
"chevron-right": "M9 5l7 7-7 7",
search: "M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14M16.2 16.2 21 21",
settings: "M4 8h10M18 8h2M4 16h4M12 16h8M16 6a2 2 0 1 0 0 4 2 2 0 0 0 0-4M10 14a2 2 0 1 0 0 4 2 2 0 0 0 0-4",
user: "M12 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8M4 21c0-4 3.6-6.5 8-6.5s8 2.5 8 6.5",
users: "M9 4a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7M2 20c0-3.6 3.1-5.8 7-5.8s7 2.2 7 5.8M16.5 4.5a3.5 3.5 0 0 1 0 7M18 14.6c2.4.7 4 2.6 4 5.4",
bell: "M6 10a6 6 0 0 1 12 0c0 4 1.5 5.5 2 6H4c.5-.5 2-2 2-6M10 20a2 2 0 0 0 4 0",
calendar: "M4 6h16v15H4zM4 10h16M8 3v4M16 3v4",
clock: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18M12 7v5.5l3.5 2",
download: "M12 3v12M7 10.5l5 5 5-5M4 20h16",
upload: "M12 16V4M7 8.5 12 3.5l5 5M4 20h16",
folder: "M3 6h6l2 2.5h10V19H3z",
file: "M6 3h8l4 4v14H6zM14 3v4h4",
image: "M4 5h16v14H4zM4 16l4.5-4.5L13 16M14.5 13.5 17 11l3 3M15.5 8.5h.01",
play: "M8 5.5v13l11-6.5z",
pause: "M9 5v14M15 5v14",
heart: "M12 20S3.5 14.6 3.5 9.2A4.7 4.7 0 0 1 12 6.5a4.7 4.7 0 0 1 8.5 2.7C20.5 14.6 12 20 12 20",
star: "M12 3.5 15 9.6l6.5.9-4.7 4.6 1.1 6.4-5.9-3-5.9 3 1.1-6.4L2.5 10.5l6.5-.9z",
bookmark: "M6 3h12v18l-6-4.5L6 21z",
lock: "M5 11h14v10H5zM8 11V7.5a4 4 0 0 1 8 0V11",
mail: "M3 5h18v14H3zM3 6l9 7 9-7",
message: "M3 5h18v12h-9l-5 4v-4H3z",
link: "M10 14a4 4 0 0 0 5.7 0l3-3A4 4 0 0 0 13 5.4l-1.5 1.5M14 10a4 4 0 0 0-5.7 0l-3 3A4 4 0 0 0 11 18.6l1.5-1.5",
external: "M14 4h6v6M20 4l-9 9M18 14v6H4V6h6",
trash: "M4 7h16M9 7V4h6v3M6 7l1 14h10l1-14M10 11v6M14 11v6",
edit: "M4 20h4L20 8l-4-4L4 16zM15 5l4 4",
copy: "M9 3h12v12H9zM15 15v6H3V9h6",
refresh: "M20 12a8 8 0 1 1-2.6-5.9M20 4v5h-5",
filter: "M3 5h18l-7 8v6l-4 2v-8z",
grid: "M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z",
list: "M8 6h13M8 12h13M8 18h13M3.5 6h.01M3.5 12h.01M3.5 18h.01",
home: "M4 11 12 4l8 7v9h-5v-6H9v6H4z",
globe: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18M3 12h18M12 3c2.5 2.6 3.8 5.6 3.8 9s-1.3 6.4-3.8 9c-2.5-2.6-3.8-5.6-3.8-9S9.5 5.6 12 3",
code: "M9 7 4 12l5 5M15 7l5 5-5 5",
terminal: "M4 5h16v14H4zM7.5 9.5 10 12l-2.5 2.5M12.5 15h4",
database: "M12 3c4.4 0 8 1.3 8 3s-3.6 3-8 3-8-1.3-8-3 3.6-3 8-3M4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3",
cloud: "M7 19a4 4 0 0 1-.4-8A6 6 0 0 1 18 10.4 4.3 4.3 0 0 1 17.5 19z",
zap: "M13 3 5 14h6l-1 7 8-11h-6z",
shield: "M12 3l8 3v6c0 5-3.4 8.3-8 9.5C7.4 20.3 4 17 4 12V6z",
eye: "M2.5 12S6 6 12 6s9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6M12 9.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5",
sun: "M12 8.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7M12 2v2.5M12 19.5V22M2 12h2.5M19.5 12H22M4.9 4.9l1.8 1.8M17.3 17.3l1.8 1.8M19.1 4.9l-1.8 1.8M6.7 17.3l-1.8 1.8",
moon: "M20 14.5A8.5 8.5 0 0 1 9.5 4 8.5 8.5 0 1 0 20 14.5",
"git-branch": "M6 4v11M6 20a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5M18 9a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5M18 9v1.5a4 4 0 0 1-4 4H9",
package: "M12 3 21 7.5v9L12 21l-9-4.5v-9zM3 7.5l9 4.5 9-4.5M12 12v9",
sparkles: "M12 3.5 13.6 8 18 9.6 13.6 11.2 12 15.7 10.4 11.2 6 9.6 10.4 8zM18.5 15l.7 2 2 .7-2 .7-.7 2-.7-2-2-.7 2-.7z",
alert: "M12 4 22 20H2zM12 10v4.5M12 17.5h.01",
info: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18M12 11v6M12 7.5h.01",
} as const;
export type IconName = keyof typeof ICONS;
/** The order the gallery walks, which is also a sensible default set. */
export const ICON_NAMES = Object.keys(ICONS) as IconName[];
export type IconProps = {
name: IconName;
/** Defaults to the brand's foreground, so an icon inherits the palette. */
color?: string;
/** Size in design pixels, scaled for the format. */
size?: number;
/** Stroke weight on the 24 grid, before scaling. */
weight?: number;
/** Draw the icon on across this many frames. Zero renders it complete. */
drawFrames?: number;
/** Frames to wait before drawing, for staggering a set of them. */
delay?: number;
style?: CSSProperties;
};
/**
* One icon, optionally drawing itself on.
*
* The draw is a dash offset rather than a clip, so it follows the path the
* way a pen would instead of wiping across the box.
*/
export const Icon = ({name, color, size = 24, weight = 2, drawFrames = 0, delay = 0, style}: IconProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const drawn =
drawFrames <= 0 ? 1 : interpolate(frame, [delay, delay + drawFrames], [0, 1], {easing: Easing.standard});
return (
<svg
aria-hidden="true"
fill="none"
height={size * scale}
stroke={color ?? brand.colors.foreground}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={weight}
style={style}
viewBox="0 0 24 24"
width={size * scale}
>
<path
d={ICONS[name]}
/* 120 comfortably exceeds the longest path here, so one number
serves every glyph and none of them ever draw short. */
strokeDasharray={drawFrames > 0 ? 120 : undefined}
strokeDashoffset={drawFrames > 0 ? 120 * (1 - drawn) : undefined}
/>
</svg>
);
};
export type IconSetProps = {
/** Which icons to show, in order. Defaults to the whole set. */
names?: IconName[];
/** Columns in the grid. */
columns?: number;
/** Frames between one icon starting and the next. */
stagger?: number;
/** Frames each icon takes to draw. */
drawFrames?: number;
/** Label under each icon. Off by default, because a wall of names is noise. */
labels?: boolean;
color?: string;
size?: number;
};
/**
* The set, drawing itself on.
*
* A gallery is the honest way to show an icon set: the whole thing at once,
* at the size it will actually be used, rather than three of them blown up.
* The stagger runs in reading order so the eye has somewhere to go.
*/
export const IconSet = ({
names = ICON_NAMES,
columns = 10,
stagger = 2,
drawFrames = 18,
labels = false,
color,
size = 44,
}: IconSetProps) => {
const brand = useBrand();
const scale = useDesignScale();
const px = (value: number) => value * scale;
return (
<Fill style={{alignItems: "center", justifyContent: "center", padding: px(80)}}>
<div
style={{
display: "grid",
gap: px(labels ? 26 : 34),
gridTemplateColumns: `repeat(${columns}, 1fr)`,
width: "100%",
}}
>
{names.map((name, index) => (
<div key={name} style={{alignItems: "center", display: "grid", gap: px(10), justifyItems: "center"}}>
<Icon color={color} delay={index * stagger} drawFrames={drawFrames} name={name} size={size} />
{labels ? (
<span
style={{
color: brand.colors.muted,
fontFamily: brand.typography.sans,
fontSize: px(15),
textAlign: "center",
}}
>
{name}
</span>
) : null}
</div>
))}
</div>
</Fill>
);
};
videos/components/icon-set/icon-set.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {IconSet} from "./icon-set";
export default defineComponentPreview({
title: "Icon set",
category: "Brand",
description: "A stroked icon set that draws itself on, as editable SVG source.",
component: IconSet,
canvas: {width: 1920, height: 1080, duration: "6s"},
controls: {
columns: {type: "number", defaultValue: 10, min: 3, max: 14, step: 1},
size: {type: "number", defaultValue: 44, min: 16, max: 120, step: 2},
stagger: {type: "number", defaultValue: 2, min: 0, max: 12, step: 1},
drawFrames: {type: "number", defaultValue: 18, min: 0, max: 60, step: 1},
labels: {type: "boolean", defaultValue: false},
},
examples: [
{name: "The set", props: {}},
{name: "Named", props: {columns: 8, labels: true, size: 40}},
{
name: "A few, large",
props: {
columns: 5,
drawFrames: 26,
names: ["sparkles", "zap", "shield", "git-branch", "terminal"],
size: 96,
stagger: 8,
},
},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {IconSet} from "../components/icon-set/icon-set";
<Video>
<Scene id="icon-set" duration="6s">
<IconSet
size={44}
drawFrames={18}
/>
</Scene>
</Video>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Brand
- Recommended duration
- 180 frames · 6s
- Minimum duration
- 60 frames · 2s
- Entrance
- 18 frames
- Exit
- 0 frames
- Reduced motion
- every icon renders complete, with no draw on
- Requires
- sans font
- Content limits
- names ≤ 56
Props
| Prop | Type | Default | Required |
|---|---|---|---|
name | IconName | — | Yes |
colorDefaults to the brand's foreground, so an icon inherits the palette. | string | — | — |
sizeSize in design pixels, scaled for the format. | number | 24 | — |
weightStroke weight on the 24 grid, before scaling. | number | 2 | — |
drawFramesDraw the icon on across this many frames. Zero renders it complete. | number | 0 | — |
delayFrames to wait before drawing, for staggering a set of them. | number | 0 | — |
style | CSSProperties | — | — |
Related components
All componentsBrand providerThe resolved brand rendered as a sheet: color, type, and motion.Identity revealAn animated product logo and name.Logo orbitProduct identity surrounded by connected integrations.Marks starterPlaceholder marks that draw themselves on, as editable SVG source.Absolute stageFull-frame surface with stable clipping and positioning.AccordionSections opening one at a time, the outgoing one closing as the next grows.