Animate Components
A component animates by writing to state on a timer — each write re-renders it.
Overview
The $this->timers service schedules the work — repeating intervals and one-shot delays — which you start from a lifecycle hook so it runs once rather than on every rebuild. Each callback writes to state, and the rebuild is the animation; you never repaint by hand. For colour, a Gradient turns a changing value into a moving shade.
Timers
Change over time comes from the $this->timers service: schedule a callback, write state inside it, and each write is a frame.
Timers fire as part of the frame loop, so a busy frame can push one late. Treat the interval as a target, not an exact deadline — fine for a cadence like a spinner, a poll or a clock, rather than millisecond-precise timing.
Start work when it mounts
A timer should start once — when the component appears, not on every rebuild. That's what lifecycle hooks are for: methods Phui calls at points in a component's life. The one you'll reach for most is onMount(), run once just after the component enters the tree — the place to start a timer, kick off a fetch, or set initial focus. A component also has onRebuild(), run after each rebuild, and beforeUnmount(), run just before it is removed; see the lifecycle reference.
onMount() runs before the component's first build, with services and the runtime context available — so it is also the place to prepare anything the first frame depends on that needs those: a scanned directory, data read from a service, a lookup table. Setup that needs neither can stay in the constructor or a property initialiser.
Animate on an interval
setInterval() runs a callback every N milliseconds. Start it in onMount() so it begins once, and writing state in the callback re-renders. A spinner cycles a frame counter:
#[State]
public int $frame = 0;
private array $chars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴'];
public function onMount(): void
{
$this->timers->setInterval(fn () => $this->frame++, 80);
}
public function build(): Node
{
return Text::make($this->chars[$this->frame % count($this->chars)]);
}Run once after a delay
setTimeout() fires a single time — for a message that dismisses itself, say:
public function onMount(): void
{
$this->timers->setTimeout(fn () => $this->visible = false, 3000);
}Cancel a timer
Both methods return an id you can pass to clear() to stop a timer early:
$id = $this->timers->setInterval(fn () => $this->tick(), 1000);
$this->timers->clear($id);You do not need to clear timers when the component unmounts — that happens for you.
Poll a source
Read from a service on an interval to reflect data that changes outside the UI:
public function onMount(): void
{
$this->timers->setInterval(function () {
$this->rows = $this->get(TaskStore::class)->latest;
}, 500);
}Gradients
A Gradient is colour that varies across space — and, shifted over time, colour that moves.
Fill with a gradient
A gradient blends between two or more stops and is accepted anywhere a colour is — background, text or border:
use Phui\Colours\Gradient;
use Phui\Colours\Hex;
$sunset = Gradient::horizontal(
Hex::from('#ff5f6d'),
Hex::from('#ffc371'),
);
Container::make($children)->bgColour($sunset);
Text::make('Phui')->colour($sunset);Gradient::vertical(...) blends top to bottom instead, and at(float $t) returns the interpolated colour at a position from 0 to 1 — useful when drawing to a canvas.
Animate a gradient
Put the two together: withPhase() returns a copy of a gradient shifted along its axis, wrapping around. Increment the phase from an interval and the gradient moves:
#[State]
public float $phase = 0.0;
public function onMount(): void
{
$this->timers->setInterval(fn () => $this->phase += 0.02, 50);
}
public function build(): Node
{
$rainbow = Gradient::horizontal(
Hex::from('#ff0000'),
Hex::from('#00ff00'),
Hex::from('#0000ff'),
);
return Text::make('loading…')->colour($rainbow->withPhase($this->phase));
}Phase and position are fractions of the gradient — 0.0 its start, 1.0 its end — and values outside that range wrap by dropping the whole part, so a raw column or tick count shifts by exactly nothing. Divide by the span first ($x / $width); see the Gradient reference.
For a loop with no seam, repeat the first stop as the last — the wrap then lands on the colour it left from.
Sweep a canvas
When drawing to a canvas you set each cell's colour yourself, and there are two ways to sweep a gradient across the cells. Either resolve the colour per cell with at(), passing the cell's fraction of the sweep — or assign the gradient itself and let the painter place each cell along it, using withPhase() to slide the whole ramp. Build the shifted gradient once per frame, outside the cell loop: every withPhase() call allocates a new gradient.
private function paint(Surface $surface): void
{
$sweep = $this->gradient->withPhase(-$this->phase);
for ($y = 0; $y < self::HEIGHT; $y++) {
for ($x = 0; $x < self::WIDTH; $x++) {
$surface->cells[$y][$x]->char = '█';
$surface->cells[$y][$x]->foregroundColour = $sweep;
}
}
}A negative phase moves the pattern in the reading direction; positive moves it back the other way.