Animation
Every animation layer, on timers: style transitions with springs, keyframes, enter/exit on list items and on a surface, layout transitions, and a JS tween.
Run it
swift run halo start --config examples/animation # from a clone
halo start --config "$(brew --prefix)/share/halo/examples/animation" # Homebrew
Source
main.tsx
// Every animation layer, driven by timers so it plays without input:
// transitions (style changes), keyframes (spinner, pulse), enter/exit (list items, toast surface),
// layout transitions (the list reflowing) and a JS tween (the counter).
import {
Box,
For,
Image,
PerScreen,
Surface,
Text,
animate,
tween,
type NativeNode,
type ScreenInfo,
} from "halo";
import { signal } from "halo/signals";
const card = "#1e1e2ef0";
const foreground = "#cdd6f4";
const muted = "#9399b2";
const palette = ["#89b4fa", "#a6e3a1", "#f9e2af", "#f38ba8", "#cba6f7", "#94e2d5"];
const tick = signal(0);
setInterval(() => tick.update((n) => n + 1), 1200);
function Showcase() {
const items = signal([0, 1, 2]);
let next = 3;
setInterval(() => {
items.update((list) =>
list.length >= 5 || (list.length > 2 && Math.random() < 0.5)
? list.slice(1)
: [...list, next++],
);
}, 900);
const total = tween(() => tick() * 137, { duration: 700, curve: "ease-out" });
let spinner: NativeNode | undefined;
let dot: NativeNode | undefined;
const showcase = (
<Box
style={{
padding: 18,
gap: 16,
background: card,
radius: 18,
borderWidth: 1,
borderColor: "#ffffff1a",
}}
>
<Box style={{ flexDirection: "row", justify: "space-between", align: "center" }}>
<Text style={{ fontSize: 12, fontWeight: "semibold", color: muted }}>
ANIMATION
</Text>
<Box style={{ flexDirection: "row", gap: 10, align: "center" }}>
<Box
ref={(node) => (dot = node)}
style={{ width: 8, height: 8, radius: 4, background: "#a6e3a1" }}
/>
<Image
ref={(node) => (spinner = node)}
symbol="arrow.triangle.2.circlepath"
size={16}
color={muted}
/>
</Box>
</Box>
<Box style={{ flexDirection: "row", align: "center", gap: 14 }}>
<Box
style={{
width: 56,
height: 56,
radius: () => (tick() % 2 ? 28 : 12),
background: () => palette[tick() % palette.length],
scale: () => (tick() % 2 ? 1.12 : 0.9),
rotate: () => (tick() % 2 ? 45 : 0),
transition: {
all: { curve: { stiffness: 220, damping: 12 } },
background: 400,
},
}}
/>
<Box style={{ gap: 2 }}>
<Text style={{ fontSize: 28, fontWeight: "bold", color: foreground }}>
{() => Math.round(total()).toLocaleString("en-US")}
</Text>
<Text style={{ fontSize: 12, color: muted }}>tweened counter</Text>
</Box>
</Box>
<Box style={{ flexDirection: "row", gap: 8, height: 32 }}>
<For each={items}>
{(item) => (
<Box
style={{
width: 52,
height: 32,
radius: 8,
background: palette[item() % palette.length],
justify: "center",
align: "center",
transition: { layout: { duration: 300, curve: "ease-in-out" } },
}}
enter={{ from: { opacity: 0, scale: 0.6 }, curve: "spring" }}
exit={{ to: { opacity: 0, translateY: -14 }, duration: 220 }}
>
<Text style={{ fontSize: 12, fontWeight: "semibold", color: "#1e1e2e" }}>
{String(item())}
</Text>
</Box>
)}
</For>
</Box>
</Box>
);
if (spinner)
animate(
spinner,
{ rotate: [0, 360] },
{ duration: 1400, repeat: Infinity, curve: "linear" },
);
if (dot)
animate(
dot,
{ opacity: [1, 0.25], scale: [1, 1.6] },
{ duration: 700, repeat: Infinity, alternate: true, curve: "ease-in-out" },
);
return showcase;
}
function Toast(props: { screen: ScreenInfo }) {
return (
<Surface
screen={props.screen}
anchor={["bottom"]}
margins={[0, 0, 40, 0]}
size={{ width: 300, height: 52 }}
layer="overlay"
visible={() => tick() % 4 < 2}
enter={{
from: { opacity: 0, translateY: 30, scale: 0.9 },
curve: { stiffness: 260, damping: 18 },
}}
exit={{ to: { opacity: 0, translateY: 20 }, duration: 200, curve: "ease-in" }}
>
<Box
style={{
flexDirection: "row",
align: "center",
gap: 10,
padding: [0, 16],
background: card,
radius: 14,
}}
>
<Image symbol="bell.fill" size={16} color="#f9e2af" />
<Text style={{ fontSize: 13, color: foreground }}>Surface enter / exit</Text>
</Box>
</Surface>
);
}
export default () => (
<PerScreen>
{(screen) => (
<>
<Surface screen={screen} size={{ width: 380, height: 220 }} layer="aboveMenu">
<Showcase />
</Surface>
<Toast screen={screen} />
</>
)}
</PerScreen>
);