Declarative, component-based UI framework for Emacs
Build reactive UIs in Emacs using familiar patterns from React and other modern UI frameworks. Define components with local state, props, lifecycle hooks, and automatic re-rendering.
The API is stable and used in real-world projects.
See it in action (click any demo to enlarge):
CI Dashboard (real, over gh)live GitHub Actions runs, drill into jobs and steps, stream logs |
Claude Chat live chat against claude -p, streamed through vui-stream
|
|
CI Pipeline Dashboard (simulated) colored status and live progress gauges |
Pomodoro Timer a countdown driven by a timer effect |
Pixel Width (vui-width-mode = char)emoji, CJK and proportional text drift when widths are counted in characters |
Pixel Width (vui-width-mode = pixel)the same table, boxes and flex rows measured in pixels |
More runnable examples are listed below.
Features
- Components — Reusable UI building blocks with props and local state
- Reactive State — Automatic re-rendering when state changes
- Hooks — vui-use-effect, vui-use-ref, vui-use-memo, vui-use-callback
- Context — Share data across component trees without prop drilling
- Layout Primitives — hstack, vstack, flex, box, table, list
- Pixel-Accurate Layout — Opt-in
vui-width-modekeeps tables and boxes aligned with emoji, CJK, and proportional fonts - Inline Mounting — Ephemeral forms inside existing buffers, without taking them over
- Error Boundaries — Graceful error handling with fallback UI
- Developer Tools — Component inspector, timing profiler, debug logging
Quick Example
;;; -*- lexical-binding: t -*- (require 'vui) ;; Define a component (vui-defcomponent counter () :state ((count 0)) :render (vui-fragment (vui-text (format "Count: %d" count)) (vui-newline) (vui-button "Increment" :on-click (lambda () (vui-set-state :count (1+ count)))))) ;; Mount it (vui-mount (vui-component 'counter) "*counter*")
Result: A buffer with text “Count: 0” and a clickable button. Each click updates the count and re-renders.
More Examples
Props and Composition
(vui-defcomponent greeting (name) :render (vui-text (format "Hello, %s!" name))) (vui-defcomponent app () :render (vui-vstack (vui-component 'greeting :name "Alice") (vui-component 'greeting :name "Bob")))
Form Input
(vui-defcomponent name-form () :state ((name "")) :render (vui-fragment (vui-text "Enter name: ") (vui-field :value name :size 20 :on-change (lambda (v) (vui-set-state :name v))) (vui-newline) (vui-text (if (string-empty-p name) "Type something..." (format "Hello, %s!" name)))))
Lifecycle Hooks
(vui-defcomponent timer () :state ((seconds 0)) :on-mount (let ((timer (run-with-timer 1 1 (vui-with-async-context (vui-set-state :seconds #'1+))))) (lambda () (cancel-timer timer))) :render (vui-text (format "Elapsed: %d seconds" seconds)))
Context for Theme
(vui-defcontext theme 'light) (vui-defcomponent themed-button (label) :render (let ((theme (vui-use-context theme-context))) ; or (use-theme) (vui-button label :face (if (eq theme 'dark) 'custom-button-pressed 'custom-button)))) (vui-defcomponent app () :render (theme-provider 'dark (vui-component 'themed-button :label "Click me")))
Installation
MELPA
(use-package vui :ensure t)
Manual
Clone this repository and add to your load-path:
(add-to-list 'load-path "/path/to/vui.el") (require 'vui)
Documentation
| Document | Description |
|---|---|
| Getting Started | Installation and first component |
| Components | Props, state, composition |
| Primitives | Text, button, field, etc. |
| Layout | hstack, vstack, table, list |
| Hooks | vui-use-effect, vui-use-ref, vui-use-memo |
| Context | Sharing data across components |
| Lifecycle | on-mount, on-update, on-unmount |
| Error Handling | Error boundaries |
| Performance | Optimization techniques |
| Developer Tools | Inspector, profiler, debugging |
| Inline Mounting | Ephemeral forms in existing buffers |
| API Reference | Complete function reference |
Deep Dives
In-depth tutorials walking through real-world usage:
- Quickstart — 15-minute introduction to props, state, and composition
- Building a File Browser — Practical walkthrough of component decomposition
- Context and Composition — Prop drilling solutions and composition patterns
- Lifecycle Hooks — on-mount, on-unmount, use-effect, use-async and cleanup patterns
- Optimisation Hooks — use-ref, use-callback, use-memo and when to use them
- Under the Hood — Virtual nodes, instances, reconciliation, and the render cycle
- Patterns and Pitfalls — Practical patterns and common mistakes
For those curious about implementation details:
- Implicit Identity — How hooks use call order as implicit identity
- Cursor Preservation — Preserving cursor position across buffer rewrites
Videos:
- Declarative UIs in Emacs with vui.el — System Crafters Live! stream exploring vui.el
Examples
See docs/examples/ for complete, runnable examples:
| # | Example | What it shows |
|---|---|---|
| 01 | Hello World | Basics from the getting started guide |
| 02 | Todo App | Add, remove, and filter items |
| 03 | Forms | Form validation, multi-step wizards, settings |
| 04 | File Browser | Directory navigation with sorting and search |
| 05 | Wine Tasting | Dynamic tables with interactive cells and computed statistics |
| 06 | Collapsible | Expandable/collapsible sections, FAQ style, nesting |
| 07 | Semantic Text | Headings, emphasis, and status messages with customizable faces |
| 08 | Typed Fields | Integer/float/symbol input with validation |
| 09 | Inline Forms | Ephemeral, validated forms that expand at point |
| 10 | Pomodoro Timer | Countdown driven by a timer effect, work/break sessions |
| 11 | CI Pipeline Dashboard | Live table with colored status and progress gauges (simulated) |
| 12 | Flex Layout | Window-filling fields, :justify modes, and :grow panels with vui-flex |
| 13 | Agent Chat | A transcript that streams above a persistent input box with vui-stream |
| 14 | Claude Chat | A live chat against the claude -p CLI, streamed through vui-stream |
| 15 | CI Dashboard | A real GitHub Actions dashboard over gh: async run table, drill-down, log streaming |
| 16 | Sticky Table | A long filterable table whose header stays visible via :sticky-header |
| 17 | Pixel Width | Emoji, CJK and proportional text in tables, boxes and flex; toggle vui-width-mode live |
| 18 | Variable Pitch | Pixel layout in a proportional font: bordered tables, boxes and flex under variable-pitch-mode, live toggles |
| 19 | Responsive Dashboard | Bordered cards reflowing through vui-grid and vui-flex :wrap: columns drop and panels stack as the width shrinks |
Available Components
Primitives
| Component | Description |
|---|---|
vui-text | Styled text |
vui-newline | Line break |
vui-space | Horizontal spacing |
vui-button | Clickable button with callback |
vui-field | Text input field |
vui-checkbox | Toggle checkbox |
vui-select | Selection from options |
vui-fragment | Group elements without wrapper |
Layout
| Component | Description |
|---|---|
vui-hstack | Horizontal layout with spacing |
vui-vstack | Vertical layout with spacing/indent |
vui-flex | Row distributing width among children; :wrap flows them into rows |
vui-grid | Responsive equal-track grid |
vui-box | Fixed-width container with alignment |
vui-table | Table with headers (optionally sticky), borders, alignment |
vui-list | Dynamic list with key-based reconcile |
Higher-Level Components (vui-components.el)
| Component | Description |
|---|---|
vui-collapsible | Expandable/collapsible section with header |
vui-typed-field | Input with type conversion and validation |
vui-integer-field, vui-float-field, etc. | Shortcuts for common types |
(require 'vui-components) (vui-collapsible :title "FAQ" (vui-text "Hidden by default, click to reveal.")) (vui-collapsible :title "Details" :initially-expanded t (vui-text "Visible on load.")) ;; Typed field with validation (vui-integer-field :value 42 :min 0 :max 100 :show-error 'inline :on-change (lambda (n) (vui-set-state :count n)))
Semantic Text Components (vui-components.el)
Thin wrappers around vui-text with customizable faces:
| Component | Inherits From |
|---|---|
vui-heading / vui-heading-N | outline-1 … outline-8 |
vui-strong | bold |
vui-italic | italic |
vui-muted | shadow |
vui-code | fixed-pitch |
vui-error | error |
vui-warning | warning |
vui-success | success |
(require 'vui-components) (vui-vstack (vui-heading-1 "Main Title") (vui-heading-2 "Subsection") (vui-strong "Important!") (vui-muted "Less important...") (vui-code "inline-code") (vui-error "Something went wrong")) ;; Or with :level for programmatic use (vui-heading "Dynamic Heading" :level depth)
Customize faces to fit your theme:
(set-face-attribute 'vui-heading-1 nil :height 1.3) (set-face-attribute 'vui-muted nil :slant 'italic)
Hooks
| Hook | Description |
|---|---|
vui-use-effect | Side effects with cleanup |
vui-use-ref | Mutable reference (no re-render on change) |
vui-use-callback | Stable callback reference |
vui-use-memo | Cached computed value |
vui-use-async | Async data loading with cache |
Using Shorter Names (Shorthands)
If you prefer the cleaner React-style names without the vui- prefix, you have two options:
Emacs 28+: Read Symbol Shorthands
Add to your file’s local variables:
;; Local Variables: ;; read-symbol-shorthands: (("defc" . "vui-defc") ("use-" . "vui-use-")) ;; End:
This lets you write defcomponent instead of vui-defcomponent and use-effect instead of vui-use-effect.
Aliases
Define aliases in your init file:
(defalias 'defcomponent 'vui-defcomponent) (defalias 'defcontext 'vui-defcontext) (defalias 'use-effect 'vui-use-effect) (defalias 'use-ref 'vui-use-ref) (defalias 'use-callback 'vui-use-callback) (defalias 'use-memo 'vui-use-memo) (defalias 'use-async 'vui-use-async)
Developer Tools
;; Inspect component tree (vui-inspect) ;; View state of all components (vui-inspect-state) ;; Profile render performance (setq vui-timing-enabled t) ;; ... interact with your app ... (vui-report-timing) ;; Debug render cycles (setq vui-debug-enabled t) (vui-debug-show)
Requirements
- Emacs 29.1 or later
- Lexical binding enabled in your Elisp files (
;;; -*- lexical-binding: t -*-) - Built-in
widget.el(included with Emacs)
Known Limitations
Emacs 29: Single-widget TAB navigation
On Emacs 29.x, pressing TAB in a buffer with only one tabbable widget (e.g., a single field or button) will error with “No buttons or fields found”. This is a bug in Emacs’s widget-move fixed in Emacs 30.
Workaround: Add a second widget, or use mouse/direct interaction. Buffers with multiple widgets work fine.
Major Mode
VUI buffers use vui-mode, a major mode derived from special-mode. This provides:
TAB/S-TAB— Navigate between widgets (buttons, fields)RET— Activate widget at pointq— Quit window (or self-insert when in a text field)g— Refresh UI (or self-insert when in a text field)- Standard
special-modebindings (hfor help, etc.)
Extending with Custom Keybindings
Users can add bindings to vui-mode-map. For example, to enable ace-link-vui for quick widget navigation:
(define-key vui-mode-map (kbd "o") #'ace-link-vui)
Deriving Custom Modes
Packages can derive their own modes from vui-mode to add custom keybindings:
(define-derived-mode my-sidebar-mode vui-mode "MySidebar" "Custom mode for my sidebar." ;; Custom keybindings (define-key my-sidebar-mode-map (kbd "q") #'my-sidebar-close) (define-key my-sidebar-mode-map (kbd "g") #'my-sidebar-refresh))
When using a derived mode, enable it before calling vui-mount or vui-render. VUI will detect the derived mode and preserve it across re-renders.
Querying Elements at Point
When you bind your own key or command, you often need to know which VUI element the cursor is on and act on it. Reach for these instead of widget-at / button-at: they are mechanism-agnostic. VUI renders buttons, checkboxes and selects as button.el text buttons and editable fields as widget.el widgets, and these functions hide that difference. Poking at the rendering mechanism directly breaks whenever it changes; this API does not.
(vui-element-at &optional POS)— the VUI element at POS (default point), ornil. An opaque handle; don’t assume how it was rendered.(vui-element-get ELEMENT PROP)— a VUI property of ELEMENT::vui-key(reconciliation key),:vui-tag(label),:vui-path(component-tree path), and so on.(vui-key-at &optional POS)— convenience for the common case: the:keyof the element at POS, ornil. Same as(vui-element-get (vui-element-at POS) :vui-key).(vui-activate &optional POS)— run the element’s action: follow a button, toggle a checkbox, open a select, submit a field. Returns non-nil when an element was found.
;; A command that opens whatever keyed row the cursor is on. (defun my-open-at-point () (interactive) (when-let* ((key (vui-key-at))) (my-open-note key)))
Architecture
vui.el implements a React-like architecture:
- Virtual DOM — Components return vnodes (virtual nodes)
- Reconciliation — Diffing algorithm to minimize DOM updates
- Component Instances — Maintain state and lifecycle across renders
- Hooks System — Composable state and effects
- Context Stack — Provider/consumer pattern for shared state
Contributing
Contributions welcome! Please:
- Check existing issues before opening new ones
- Include tests for new features
- Follow existing code style
- Update documentation as needed
Related Projects
- ace-link-vui — Ace-link style navigation for VUI buffers
Built with VUI
- d12frosted/vulpea-ui — Sidebar UI for vulpea notes
- d12frosted/vulpea-journal — Journaling system with calendar widgets
- d12frosted/brb — Barberry Garden management system
- ahyatt/ekg — The Emacs knowledge graph, app for notes and structured data
- ahyatt/emacs-bluesky — Bluesky client for Emacs
- ChristianTietze/beads.el — Emacs interface to the Beads issue tracking system
- r0man/beads.el — Emacs mode for the Beads issue tracker (independent client)
- r0man/gastown.el — Emacs mode for the Gastown agent orchestrator
- zonuexe/unicode-inspector.el — Interactive Unicode character inspector
- zonuexe/eijiro-search.el — Interactive English-Japanese dictionary viewer
License
GPL-3.0
Acknowledgments
Inspired by:
- React (component model, hooks)
- Svelte (reactivity)
- SolidJS (fine-grained updates)
- Emacs widget.el (underlying implementation)
Support
If you enjoy this project, you can support its development via GitHub Sponsors or Patreon.



