GitHub

MELPA version Sponsor

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

CI dashboard demo

Claude Chat
live chat against claude -p, streamed through vui-stream

Claude Chat demo

CI Pipeline Dashboard (simulated)
colored status and live progress gauges

CI pipeline dashboard demo

Pomodoro Timer
a countdown driven by a timer effect

Pomodoro timer

Pixel Width (vui-width-mode = char)
emoji, CJK and proportional text drift when widths are counted in characters

Pixel width demo in char mode: columns drift

Pixel Width (vui-width-mode = pixel)
the same table, boxes and flex rows measured in pixels

Pixel width demo in pixel mode: everything aligned

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-mode keeps 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

DocumentDescription
Getting StartedInstallation and first component
ComponentsProps, state, composition
PrimitivesText, button, field, etc.
Layouthstack, vstack, table, list
Hooksvui-use-effect, vui-use-ref, vui-use-memo
ContextSharing data across components
Lifecycleon-mount, on-update, on-unmount
Error HandlingError boundaries
PerformanceOptimization techniques
Developer ToolsInspector, profiler, debugging
Inline MountingEphemeral forms in existing buffers
API ReferenceComplete function reference

Deep Dives

In-depth tutorials walking through real-world usage:

For those curious about implementation details:

Videos:

Examples

See docs/examples/ for complete, runnable examples:

#ExampleWhat it shows
01Hello WorldBasics from the getting started guide
02Todo AppAdd, remove, and filter items
03FormsForm validation, multi-step wizards, settings
04File BrowserDirectory navigation with sorting and search
05Wine TastingDynamic tables with interactive cells and computed statistics
06CollapsibleExpandable/collapsible sections, FAQ style, nesting
07Semantic TextHeadings, emphasis, and status messages with customizable faces
08Typed FieldsInteger/float/symbol input with validation
09Inline FormsEphemeral, validated forms that expand at point
10Pomodoro TimerCountdown driven by a timer effect, work/break sessions
11CI Pipeline DashboardLive table with colored status and progress gauges (simulated)
12Flex LayoutWindow-filling fields, :justify modes, and :grow panels with vui-flex
13Agent ChatA transcript that streams above a persistent input box with vui-stream
14Claude ChatA live chat against the claude -p CLI, streamed through vui-stream
15CI DashboardA real GitHub Actions dashboard over gh: async run table, drill-down, log streaming
16Sticky TableA long filterable table whose header stays visible via :sticky-header
17Pixel WidthEmoji, CJK and proportional text in tables, boxes and flex; toggle vui-width-mode live
18Variable PitchPixel layout in a proportional font: bordered tables, boxes and flex under variable-pitch-mode, live toggles
19Responsive DashboardBordered cards reflowing through vui-grid and vui-flex :wrap: columns drop and panels stack as the width shrinks

Available Components

Primitives

ComponentDescription
vui-textStyled text
vui-newlineLine break
vui-spaceHorizontal spacing
vui-buttonClickable button with callback
vui-fieldText input field
vui-checkboxToggle checkbox
vui-selectSelection from options
vui-fragmentGroup elements without wrapper

Layout

ComponentDescription
vui-hstackHorizontal layout with spacing
vui-vstackVertical layout with spacing/indent
vui-flexRow distributing width among children; :wrap flows them into rows
vui-gridResponsive equal-track grid
vui-boxFixed-width container with alignment
vui-tableTable with headers (optionally sticky), borders, alignment
vui-listDynamic list with key-based reconcile

Higher-Level Components (vui-components.el)

ComponentDescription
vui-collapsibleExpandable/collapsible section with header
vui-typed-fieldInput 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:

ComponentInherits From
vui-heading / vui-heading-Noutline-1outline-8
vui-strongbold
vui-italicitalic
vui-mutedshadow
vui-codefixed-pitch
vui-errorerror
vui-warningwarning
vui-successsuccess
(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

HookDescription
vui-use-effectSide effects with cleanup
vui-use-refMutable reference (no re-render on change)
vui-use-callbackStable callback reference
vui-use-memoCached computed value
vui-use-asyncAsync 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 point
  • q — Quit window (or self-insert when in a text field)
  • g — Refresh UI (or self-insert when in a text field)
  • Standard special-mode bindings (h for 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), or nil. 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 :key of the element at POS, or nil. 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:

  1. Virtual DOM — Components return vnodes (virtual nodes)
  2. Reconciliation — Diffing algorithm to minimize DOM updates
  3. Component Instances — Maintain state and lifecycle across renders
  4. Hooks System — Composable state and effects
  5. Context Stack — Provider/consumer pattern for shared state

Contributing

Contributions welcome! Please:

  1. Check existing issues before opening new ones
  2. Include tests for new features
  3. Follow existing code style
  4. Update documentation as needed

Related Projects

  • ace-link-vui — Ace-link style navigation for VUI buffers

Built with VUI

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.

Read the original on github.com ↗