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.
- 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, box, table, list
- Error Boundaries — Graceful error handling with fallback UI
- Developer Tools — Component inspector, timing profiler, debug logging
(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.
(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")))(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)))))(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)))(vui-defcontext theme 'light)
(vui-defcomponent themed-button (label)
:render
(let ((theme (vui-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")))(use-package vui
:ensure t)Clone this repository and add to your load-path:
(add-to-list 'load-path "/path/to/vui.el")
(require 'vui)| 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 |
| API Reference | Complete function reference |
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
See docs/examples/ for complete, runnable examples:
- Hello World — Basic examples from the getting started guide
- Todo App — Full todo application with add/remove/filter
- Forms — Form validation, multi-step wizards, settings
- File Browser — Directory navigation with sorting and search
- Wine Tasting — Dynamic tables with interactive cells, computed statistics
| 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 |
| Component | Description |
|---|---|
vui-hstack | Horizontal layout with spacing |
vui-vstack | Vertical layout with spacing/indent |
vui-box | Fixed-width container with alignment |
vui-table | Table with headers, borders, alignment |
vui-list | Dynamic list with key-based reconcile |
| 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 |
If you prefer the cleaner React-style names without the vui- prefix, you have two options:
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.
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);; 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)- Emacs 27.1 or later
- Built-in
widget.el(included with Emacs)
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.
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 point- Standard
special-modebindings (qto quit,gto revert, etc.)
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)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.
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
Contributions welcome! Please:
- Check existing issues before opening new ones
- Include tests for new features
- Follow existing code style
- Update documentation as needed
- ace-link-vui — Ace-link style navigation for VUI buffers
- vulpea-ui — Sidebar UI for vulpea notes
- vulpea-journal — Journaling system with calendar widgets
- brb — Barberry Garden management system
GPL-3.0
Inspired by:
- React (component model, hooks)
- Svelte (reactivity)
- SolidJS (fine-grained updates)
- Emacs widget.el (underlying implementation)