Build Components
Everything you draw in Phui is a component: a class that extends Component and returns a tree of nodes from build().
Overview
A node is a description of one piece of the UI — a line of text, a box holding others — not terminal output. You build the tree and Phui renders it, keeping it in sync as it changes, so you never paint by hand. A few node types cover everything: Text and Container build most of a UI, while Canvas and VirtualContainer handle drawing cell by cell and lists too long to build in full.
Basic nodes
Text and containers
Two nodes cover most of a UI:
A container takes its children as an array. Give each child its own variable and compose them in the return — a build() reads best as a flat list of parts:
use Phui\Nodes\Container;
use Phui\Nodes\Text;
public function build(): Node
{
$header = Text::make('Tasks');
$item = Text::make('Ship docs');
return Container::make([$header, $item]);
}Style the nodes
Styling chains directly onto a node. Make the header bold, and colour text with colour() — it takes a hex string or a Colour:
$header = Text::make('Tasks')->bold();
$item = Text::make('Ship docs')->colour('#a3be8c');bold() is one of several decorations — italic(), underline(), dim() and more are listed on the Text reference. Fill a node's background with bgColour().
Mix styles in one line
colour() and bold() style a whole Text node. When a single line needs several styles at once — a coloured status dot, a bold label, a muted detail — build a StyledString from runs and pass it as the content:
use Phui\Colours\Hex;
use Phui\Style\TextDecorationType;
use Phui\Text\StyledString;
$status = StyledString::make('● ', Hex::from('#a3be8c'))
->append('Connected', typography: [TextDecorationType::Bold])
->append(' 12ms', Hex::from('#6c7086'));
Text::make($status);Each append() adds a run with its own colour and decorations, so a single line mixes styles without being split into several nodes. Prefer this to a row of Text nodes where you can: every node is a little more for Phui to lay out and paint, so one styled string is cheaper than many Text nodes.
Lay them out and space them
A container arranges its children along an axis. Stack them vertically with axis() (horizontal places them side by side), and use alignX() / alignY() to position them within its space.
Then give it room to breathe: gap() spaces the children apart, padding() adds space inside the edges (margin() adds it outside), and border() frames it:
return Container::make([$header, $item])
->axis('vertical')
->gap(1)
->padding(1)
->border('rounded');Render dynamic content and lists
Often you'll want to turn a list of items into nodes. Map an array to nodes with Node::each(), which keys each one so Phui can track it between rebuilds. The key matters: without one, Phui can't tell whether two consecutive nodes of the same type are the same node or not.
use Phui\Nodes\Node;
$tasks = ['Ship docs', 'Write tests', 'Fix the bug'];
$items = Node::each(
$tasks,
fn (string $task) => Text::make('• '.$task),
fn (string $task) => $task,
);Spread the result into the container's children. To show a node only under a condition — an empty-state message when there's nothing to list — wrap it in Node::when():
$empty = Node::when($tasks === [], Text::make('Nothing to do'));
return Container::make([$header, ...$items, $empty])
->axis('vertical')
->gap(1)
->padding(1)
->border('rounded');Conditionally showing a node would otherwise need a key; when() is how the framework spares you that — a hidden node keeps its slot and draws nothing, so nothing around it shifts.
Size to the available space
Every node decides its size per axis. By default a node fits its content, but you can make it fill the space instead:
grow()— take all the space left over.fit()— shrink to the content.fixedX(24)— an exact number of columns.percentY(50)— a share of the parent.
Grow the whole list to fill the space its parent gives it:
Container::make([$header, ...$items])->axis('vertical')->grow();Build from the available space
Sometimes content needs to know its room before it can be built — a divider that fills the width, say. Pass a closure where a node's content goes and it receives a Space with the dimensions to build against:
use Phui\Nodes\Space;
$divider = Text::make(fn (Space $space) => str_repeat('─', $space->width))
->growX();The closure is re-run whenever the space changes, so the divider re-fills after a resize. Space-built content needs an externally set size — grow or a fixed value — so there's a width to build against.
Scroll the overflow
When a container's children don't fit the space it's given, let the user reach the rest by making it scroll. scrollY() scrolls vertically, scrollX() horizontally, and scroll() both:
Container::make([$header, ...$items])
->axis('vertical')
->fixedY(10)
->scrollY();The container needs a bounded size to scroll within — a fixedY() here, or a grow() that a parent constrains. The mouse wheel scrolls it, and a scrollbar appears only while that axis actually overflows.
To move the scroll position yourself — to keep a selected row in view — see Scroll programmatically.
Advanced nodes
Text and Container handle most of a UI, but two more node types cover the cases they don't: Canvas, for drawing cell by cell, and VirtualContainer, for lists too long to build in full.
Draw on a canvas
When containers and text are not enough — a sparkline, a chart, a custom widget — a Canvas lets you paint a fixed grid of cells directly.
Paint a grid
Canvas::make() takes a fixed width and height; the closure you pass to draw() receives a Surface and writes to its cells, indexed [y][x]:
use Phui\Canvas\Surface;
use Phui\Colours\Hex;
use Phui\Nodes\Canvas;
Canvas::make(20, 5)->draw(function (Surface $surface) {
$surface->clear();
$surface->cells[0][0]->char = '█';
$surface->cells[0][0]->foregroundColour = Hex::from('#a3be8c');
$surface->cells[0][0]->backgroundColour = Hex::from('#1e1e2e');
});Each SurfaceCell has a char and optional foregroundColour / backgroundColour. clear() resets every cell — call it at the top of the callback when you redraw from scratch, optionally passing a background colour to fill.
Draw from state
draw() re-runs whenever the component rebuilds, so drive the drawing from state and never repaint by hand. A bar that fills one cell per unit:
final class Bar extends Component
{
#[Prop]
private int $value = 0;
public function build(): Node
{
return Canvas::make(20, 1)->draw(function (Surface $surface) {
$surface->clear();
for ($x = 0; $x < min($this->value, 20); $x++) {
$surface->cells[0][$x]->char = '█';
}
});
}
}Changing $value rebuilds the component, which re-runs the draw callback with the new count.
Shade with a gradient
Combine a canvas with a Gradient's at() to colour cells across a range — at(float $t) returns the interpolated colour at a position from 0 to 1:
$heat = Gradient::horizontal(Hex::from('#88c0d0'), Hex::from('#bf616a'));
Canvas::make(20, 1)->draw(function (Surface $surface) use ($heat) {
$surface->clear();
for ($x = 0; $x < 20; $x++) {
$surface->cells[0][$x]->char = '█';
$surface->cells[0][$x]->foregroundColour = $heat->at($x / 19);
}
});Virtualise a long list
A list with thousands of rows built as ordinary container children builds and paints every row on every rebuild. VirtualContainer renders only the rows scrolled into view — the rest exist as nothing more than a count.
Render only what is visible
Give it a row count, a builder called with each visible index, and a scrolling axis:
use Phui\Nodes\VirtualContainer;
public function build(): Node
{
return VirtualContainer::make(
itemCount: count($this->tasks),
itemHeight: 1,
builder: fn (int $index) => Text::make($this->tasks[$index]),
)
->fixedY(10)
->scrollY();
}This creates a vertical scroll container, but Phui lays out and renders only the items currently on screen, so it stays fast no matter how long the list grows.
Give it a fixed row height
Pass itemHeight when every row is the same height — it lets Phui jump straight to the right scroll offset without measuring. It is exact, not a maximum: taller content is clipped to the row, shorter content still fills the slot.
Omit itemHeight only when rows genuinely vary. Phui then measures each row as it scrolls in and caches the result, which is significantly more expensive — prefer a fixed height whenever you can. That cache is append-only: rows added to the end are picked up for free, but inserting, deleting, reordering or editing needs a version() change so Phui knows the cache is stale.
Give it a version that changes whenever the list does. version() takes any int or string and clears the cache whenever the value differs from the last frame, so the cheapest reliable choice is a counter you bump right where you mutate the list — next to the change, so it can't be forgotten:
#[State]
public int $rev = 0;
public function toggle(int $i): void
{
$this->tasks[$i]->ok = ! $this->tasks[$i]->ok;
$this->rev++;
}
public function build(): Node
{
return VirtualContainer::make(
itemCount: count($this->tasks),
builder: fn (int $i) => $this->row($i),
)->version($this->rev);
}A counter costs nothing regardless of list size. Don't hash the rows into a signature instead — that walks the whole list every frame, which is exactly the work a virtual list exists to avoid. If your data is immutable, version() can take the collection's own identity or revision instead of a hand-kept counter. With a fixed itemHeight there is no measurement cache, so no version is needed.
Nest a component in a row
The builder must return an element node — a Text, Container or Canvas. Returning a component or modal throws, because a virtual container derives each row's offset from the one above without laying the list out, and a component could resize its row from underneath it.
Components are welcome inside a fixed-height row, where the height is the container's to enforce:
VirtualContainer::make(
itemCount: count($this->tasks),
itemHeight: 1,
builder: fn (int $i) => Container::make([
StatusBadge::mount()->props(done: $this->tasks[$i]->ok),
Text::make($this->tasks[$i]->name),
]),
);A measured-height container (no itemHeight) cannot hold a component anywhere, because measuring a row means building it — and building every row is exactly what virtualising the list avoids.