Layout primitives help you arrange UI elements in structured ways: horizontally, vertically, in boxes, or in tables.
Arranges children horizontally with optional spacing:
(vui-hstack
(vui-text "One")
(vui-text "Two")
(vui-text "Three"))Output:
One Two Three
Default spacing is 1 character. Customize with :spacing:
;; No spacing
(vui-hstack :spacing 0
(vui-text "A")
(vui-text "B")
(vui-text "C"))
;; Output: ABC
;; Extra spacing
(vui-hstack :spacing 3
(vui-text "One")
(vui-text "Two"))
;; Output: One Two(vui-hstack :spacing 2
(vui-button "Save" :on-click #'save-data)
(vui-button "Cancel" :on-click #'cancel)
(vui-button "Delete" :on-click #'delete-item
:face '(:foreground "red")))Output:
[Save] [Cancel] [Delete]
Arranges children vertically with optional spacing and indentation:
(vui-vstack
(vui-text "Line 1")
(vui-text "Line 2")
(vui-text "Line 3"))Output:
Line 1 Line 2 Line 3
Add blank lines between items with :spacing:
(vui-vstack :spacing 1
(vui-text "Paragraph 1")
(vui-text "Paragraph 2")
(vui-text "Paragraph 3"))Output:
Paragraph 1 Paragraph 2 Paragraph 3
Indent all children with :indent:
(vui-vstack :indent 4
(vui-text "- Item 1")
(vui-text "- Item 2")
(vui-text "- Item 3"))Output:
- Item 1 - Item 2 - Item 3
(vui-fragment
(vui-text "Tasks:")
(vui-newline)
(vui-vstack :spacing 1 :indent 2
(vui-text "• Write documentation")
(vui-text "• Add tests")
(vui-text "• Deploy")))Output:
Tasks: • Write documentation • Add tests • Deploy
Creates a fixed-width container with text alignment:
(vui-box (vui-text "Centered")
:width 20
:align :center)Output (20 chars wide):
Centered
| Value | Description |
|---|---|
:left | Align to left (default) |
:center | Center in box |
:right | Align to right |
(vui-vstack
(vui-box (vui-text "Left") :width 20 :align :left)
(vui-box (vui-text "Center") :width 20 :align :center)
(vui-box (vui-text "Right") :width 20 :align :right))Output:
Left
Center
Right
Add padding inside the box:
(vui-box (vui-text "Padded")
:width 20
:align :center
:padding-left 2
:padding-right 2)The padding reduces the available content width.
(vui-vstack
(vui-hstack
(vui-box (vui-text "Name:") :width 10 :align :right)
(vui-field :value name :size 20))
(vui-hstack
(vui-box (vui-text "Email:") :width 10 :align :right)
(vui-field :value email :size 20))
(vui-hstack
(vui-box (vui-text "Phone:") :width 10 :align :right)
(vui-field :value phone :size 20)))Output:
Name: [ ] Email: [ ] Phone: [ ]
Creates formatted tables with columns, rows, and optional borders.
(vui-table
:columns '((:min-width 8) (:min-width 10) (:min-width 8))
:rows '(("Alice" "Developer" "NYC")
("Bob" "Designer" "LA")
("Carol" "Manager" "Chicago")))Output:
Alice Developer NYC Bob Designer LA Carol Manager Chicago
(vui-table
:columns '((:header "Name" :width 8)
(:header "Role" :width 10)
(:header "Location" :width 10))
:rows '(("Alice" "Developer" "NYC")
("Bob" "Designer" "LA"))
:border :ascii)Output:
+----------+------------+------------+ | Name | Role | Location | +----------+------------+------------+ | Alice | Developer | NYC | | Bob | Designer | LA | +----------+------------+------------+
Bordered tables automatically add padding around cell content (| value | instead of |value|) for readability.
(vui-table
:columns '((:header "Name" :width 8)
(:header "Role" :width 10))
:rows '(("Alice" "Developer")
("Bob" "Designer"))
:border :unicode)Output:
┌──────────┬────────────┐ │ Name │ Role │ ├──────────┼────────────┤ │ Alice │ Developer │ │ Bob │ Designer │ └──────────┴────────────┘
(vui-table
:columns '((:header "ID" :width 5 :align :right)
(:header "Product" :width 12 :align :left)
(:header "Price" :width 8 :align :right))
:rows '(("1" "Widget" "$9.99")
("2" "Gadget" "$19.99")
("3" "Gizmo" "$29.99"))
:border :ascii)Output:
+-------+--------------+----------+ | ID | Product | Price | +-------+--------------+----------+ | 1 | Widget | $9.99 | | 2 | Gadget | $19.99 | | 3 | Gizmo | $29.99 | +-------+--------------+----------+
| Property | Description |
|---|---|
:header | Column header text |
:width | Target width for cell content |
:min-width | Minimum width, expands as needed |
:grow | If t, pad short content and expand for long |
:truncate | If t, truncate long content with “…” |
:align | :left, :center, or :right |
The :width, :grow, and :truncate options interact to control how columns handle content that doesn’t match the declared width:
:width | :grow | :truncate | Content vs Width | Result |
|---|---|---|---|---|
| W | nil | nil | content < W | Column shrinks to content size |
| W | nil | nil | content > W | Overflow with broken bar (¦) |
| W | t | nil | content < W | Column = W, content padded |
| W | t | nil | content > W | Column expands to fit content |
| W | nil | t | content < W | Column shrinks to content size |
| W | nil | t | content > W | Column = W, content truncated with “…” |
| W | t | t | content < W | Column = W, content padded |
| W | t | t | content > W | Column = W, content truncated with “…” |
| nil | - | - | any | Column auto-sizes to content |
Notes:
:grow tacts as a minimum width guarantee:truncate tacts as a maximum width guarantee- Without
:grow, short content shrinks the column - Without
:truncate, long content either overflows (¦) or expands the column
Table cells can contain any vnode, including buttons, fields, and components:
(vui-table
:columns '((:header "Item" :width 12)
(:header "Qty" :width 6)
(:header "Action" :width 10))
:rows `(("Apples" "5" ,(vui-button "[Edit]" :on-click edit-apples))
("Oranges" "3" ,(vui-button "[Edit]" :on-click edit-oranges)))
:border :ascii)For interactive tables with complex state, embed components in cells:
(vui-table
:columns '((:header "Name" :width 15)
(:header "Score" :width 8))
:rows (mapcar (lambda (item)
(list (plist-get item :name)
(vui-component 'editable-score
:key (plist-get item :id)
:value (plist-get item :score)
:on-change handle-change)))
items)
:border :ascii)Calculate column widths dynamically based on content:
(let* ((max-name-len (apply #'max (mapcar #'length names)))
(col-width (max 18 (min 48 (+ max-name-len 2))))) ; min 18, max 48
(vui-table
:columns `((:header "Name" :width ,col-width)
(:header "Value" :width 10))
:rows ...))See docs/examples/05-wine-tasting.el for a complete example with:
- Interactive buttons in table cells
- Dynamic column width calculation
- Real-time statistics updates
Renders a list of items using a render function. Returns a vstack for vertical
lists (default) or hstack for horizontal lists, ensuring proper indent propagation.
(vui-list '("Apple" "Banana" "Cherry")
(lambda (item)
(vui-text (format "• %s" item))))Output:
• Apple • Banana • Cherry
For efficient updates, provide a key function:
(vui-list todos
(lambda (todo)
(vui-component 'todo-item :todo todo))
(lambda (todo)
(plist-get todo :id))) ; Key functionThe key function returns a unique identifier for each item.
Lists properly inherit indent from parent containers, or you can set it directly:
;; Inherits indent from parent vstack
(vui-vstack :indent 2
(vui-text "Items:")
(vui-list items #'vui-text))
;; Or set indent directly
(vui-list items #'vui-text nil :indent 2)(vui-defcomponent product-list ()
:state ((products '((:id 1 :name "Widget" :price 9.99)
(:id 2 :name "Gadget" :price 19.99))))
:render
(vui-vstack :spacing 1
(vui-list products
(lambda (product)
(vui-hstack
(vui-text (plist-get product :name))
(vui-text (format " - $%.2f"
(plist-get product :price)))))
(lambda (product)
(plist-get product :id)))))Sometimes a child should fill whatever space is left rather than have a
fixed width - a field stretching between a label and a button, a footer
with content pushed to both edges. vui-flex renders a row with a known
total width and distributes the leftover:
(vui-flex :width 'fill-column
(vui-text "Name:")
(vui-flex-item :grow 1
(lambda (width) (vui-field :size width :key 'name)))
(vui-button "Save"))Children render at their natural width. A child wrapped in
vui-flex-item takes a share of the leftover, proportional to its
:grow weight. When the wrapped child is a function, it receives its
allotted width - that is what lets a field’s :size be computed instead
of guessed. A plain vnode child is padded inside a box instead, which is
enough for text and backgrounds:
;; Two growers splitting leftover 1:2
(vui-flex :width 40
(vui-flex-item :grow 1 (vui-text "left"))
(vui-flex-item :grow 2 (vui-text "right"))):width accepts a number, fill-column (the default), window, or a
function resolved at render time. Inside an indented vui-vstack, the
inherited indent is subtracted automatically - no more
(- fill-column 20) arithmetic.
When nothing grows, :justify places the leftover:
(vui-flex :width 'fill-column :justify :space-between
(vui-text "v1.2.0")
(vui-text "47 items"))
;; v1.2.0 47 itemsRows are single-line by design. If the children’s natural widths already exceed the total, everything renders at natural width and growers get zero - the row degrades instead of erroring.
For live resizing, use window or a function as :width and enable
vui-rerender-on-resize in the buffer:
(vui-flex :width (lambda () (- (window-width) 2)) ...)
(with-current-buffer "*my-app*"
(vui-rerender-on-resize))Layouts can be nested for complex structures:
(vui-vstack :spacing 1
;; Header row
(vui-hstack :spacing 2
(vui-text "Dashboard" :face 'bold)
(vui-button "Refresh"))
;; Two-column content
(vui-hstack :spacing 4
;; Left column
(vui-vstack
(vui-text "Statistics")
(vui-text "Users: 100")
(vui-text "Sales: 50"))
;; Right column
(vui-vstack
(vui-text "Actions")
(vui-button "Add User")
(vui-button "View Report"))))Stacks, boxes, and lists accept :face and :keymap options that apply
to their whole rendered extent - including the whitespace the container
itself inserts (separators, padding, indentation), which cannot be
styled child-by-child.
As with vui-text, :face accepts either a face symbol or an anonymous
face spec such as (:inherit highlight :weight bold) - see Primitives for
the forms and gotchas.
The face is layered beneath the children’s own faces, so children keep their styling while everything else gets the container face. This makes selected-row highlighting one prop:
(vui-list items
(lambda (item)
(vui-hstack
:face (when (equal item selected) 'highlight)
(vui-text (item-name item))
(vui-button "open" :on-click (lambda () (open item)))))
#'item-id)The keymap cascades innermost-first: a button’s own keymap wins for
keys it defines (default buttons keep RET / TAB), unbound keys fall
through to the container’s map, then to the buffer’s usual keymaps.
Input fields keep their own keymap.
(vui-vstack :keymap my-section-map
(vui-text "Section content")
(vui-button "Action" :keymap my-button-map))
;; On the button: my-button-map > my-section-map > vui-mode-map
;; On the text: my-section-map > vui-mode-mapFor arbitrary content that is not a container, the same styling is
available through the vui-region wrapper:
(vui-region :face 'highlight :keymap my-map
(vui-fragment child1 child2))| Primitive | Purpose | Key Props |
|---|---|---|
vui-hstack | Horizontal arrangement | :spacing, :face, :keymap |
vui-vstack | Vertical arrangement | :spacing, :indent, :face, :keymap |
vui-flex | Width distribution | :width, :justify, vui-flex-item :grow |
vui-box | Fixed-width container | :width, :align, :padding-left/right |
vui-table | Tabular data | :columns, :rows, :border |
vui-list | Dynamic list rendering | items, render-fn, key-fn, :indent, :spacing |
vui-region | Styling wrapper | :face, :keymap |
Exercise: Create a
dashboardcomponent that displays:
- A title “My Dashboard” at the top
- A table of tasks with columns: Status, Task, Due Date
- A button bar at the bottom with “Add Task” and “Clear Done” buttons
(vui-defcomponent dashboard ()
:state ((tasks '(("[ ]" "Write docs" "2024-01-15")
("[X]" "Add tests" "2024-01-10")
("[ ]" "Deploy" "2024-01-20"))))
:render
;; Your implementation here
)- Hooks - use-effect, use-ref, use-memo
- Primitives - Basic UI elements