This guide covers the building blocks for constructing screens in render/2: widgets, layout, styles, and events. Everything here works identically in both the Callback Runtime and Reducer Runtime.

Layout

render/2 returns a list of {widget, rect} tuples. Each %ExRatatui.Layout.Rect{} defines a rectangular area on the screen. Use ExRatatui.Layout.split/3 to divide areas into sub-regions using constraints:

alias ExRatatui.Layout
alias ExRatatui.Layout.Rect

area = %Rect{x: 0, y: 0, width: 80, height: 24}

# Three-row layout: header, body, footer
[header, body, footer] = Layout.split(area, :vertical, [
  {:length, 3},
  {:min, 0},
  {:length, 1}
])

# Split body into sidebar + main
[sidebar, main] = Layout.split(body, :horizontal, [
  {:percentage, 30},
  {:percentage, 70}
])

Constraint Types

ConstraintDescription
{:percentage, n}Percentage of the available space (0–100)
{:length, n}Exact number of rows or columns
{:min, n}At least n rows/columns, expands to fill remaining space
{:max, n}At most n rows/columns
{:ratio, num, den}Fraction of available space (e.g., {:ratio, 1, 3} for one-third)

Styles

Styles control foreground color, background color, and text modifiers:

alias ExRatatui.Style

# Named colors
%Style{fg: :green, bg: :black}

# RGB
%Style{fg: {:rgb, 255, 100, 0}}

# 256-color indexed
%Style{fg: {:indexed, 42}}

# Modifiers
%Style{modifiers: [:bold, :dim, :italic, :underlined, :crossed_out, :reversed]}

Named colors: :black, :red, :green, :yellow, :blue, :magenta, :cyan, :gray, :dark_gray, :light_red, :light_green, :light_yellow, :light_blue, :light_magenta, :light_cyan, :white, :reset.

Styles can be applied to most widgets via the :style field, and many widgets accept additional style fields for specific parts (e.g., highlight_style, border_style).

Rich Text

Text fields on many widgets accept more than a plain string: you can pass a %ExRatatui.Text.Span{}, a %ExRatatui.Text.Line{}, a list of spans, or any mix — letting a single string of output carry per-span colors and modifiers.

alias ExRatatui.Text.{Line, Span}
alias ExRatatui.Style

# A single styled run
Span.new("error", style: %Style{fg: :red, modifiers: [:bold]})

# Multiple styled runs on one line
Line.new([
  Span.new(" ok ", style: %Style{fg: :green}),
  Span.new(" Build ", style: %Style{fg: :yellow, modifiers: [:bold]})
])

# Line-level overrides: a style layered over spans + per-line alignment
Line.new([Span.new("centered")], style: %Style{modifiers: [:bold]}, alignment: :center)

Widgets that accept rich text on their text-bearing fields:

WidgetField(s)
Paragraph:text
Listeach items entry
Tableeach cell in :rows, each :header cell
Tabseach :titles entry
Block:title (single-line only)

Accepted shapes on these fields: String.t(), %Span{}, %Line{}, or [%Span{}]. Plain strings continue to work everywhere. Fields that are semantically single-line (table cells, tab titles, block titles) raise if you pass a string with embedded newlines.

Events

Terminal events are polled automatically by the runtime. In the Callback Runtime, they arrive in handle_event/2. In the Reducer Runtime, they arrive as {:event, event} in update/2.

Key Events

%ExRatatui.Event.Key{
  code: "q",          # key name: "a"-"z", "up", "down", "enter", "esc", "tab", etc.
  kind: "press",      # "press", "release", or "repeat"
  modifiers: []       # list of "ctrl", "alt", "shift", "super", "hyper", "meta"
}

Mouse Events

%ExRatatui.Event.Mouse{
  kind: "down",       # "down", "up", "drag", "moved", "scroll_down", "scroll_up"
  column: 10,
  row: 5,
  modifiers: []
}

Resize Events

%ExRatatui.Event.Resize{
  width: 120,
  height: 40
}

The runtime automatically re-renders on resize — you don't need to handle resize events unless your app needs to react to size changes in its state.

Widgets

Paragraph

Displays text with support for alignment, wrapping, and scrolling.

%Paragraph{
  text: "Hello, world!\nSecond line.",
  style: %Style{fg: :cyan, modifiers: [:bold]},
  alignment: :center,
  wrap: true
}

Block

A container with borders and a title. Any widget can be wrapped inside a Block via its :block field.

%Block{
  title: "My Panel",
  borders: [:all],
  border_type: :rounded,
  border_style: %Style{fg: :blue}
}

# Compose with other widgets:
%Paragraph{
  text: "Inside a box",
  block: %Block{title: "Title", borders: [:all]}
}

Border types: :plain, :rounded, :double, :thick.

List

A selectable list with highlight support for the current item.

%List{
  items: ["Elixir", "Rust", "Haskell"],
  highlight_style: %Style{fg: :yellow, modifiers: [:bold]},
  highlight_symbol: " > ",
  selected: 0,
  block: %Block{title: " Languages ", borders: [:all]}
}

Table

A table with headers, rows, and column width constraints.

%Table{
  rows: [["Alice", "30"], ["Bob", "25"]],
  header: ["Name", "Age"],
  widths: [{:length, 15}, {:length, 10}],
  highlight_style: %Style{fg: :yellow},
  selected: 0
}

Gauge

A progress bar that fills proportionally to a given ratio.

%Gauge{
  ratio: 0.75,
  label: "75%",
  gauge_style: %Style{fg: :green}
}

LineGauge

A thin single-line progress bar using line-drawing characters, with separate styles for the filled and unfilled portions.

%LineGauge{
  ratio: 0.6,
  label: "60%",
  filled_style: %Style{fg: :green},
  unfilled_style: %Style{fg: :dark_gray}
}

BarChart

Vertical or horizontal bar chart. :data is a list of %Bar{} structs, each with a plain-string :label and non-negative integer :value. Chart-level :bar_style, :value_style, and :label_style apply to every bar; individual bars override color via :style and replace the numeric display via :text_value. When :max is nil the chart auto-scales to the largest value.

alias ExRatatui.Widgets.{Bar, BarChart}

%BarChart{
  data: [
    %Bar{label: "Elixir", value: 80},
    %Bar{label: "Rust", value: 95, style: %Style{fg: :red}, text_value: "95!"},
    %Bar{label: "Go", value: 60}
  ],
  bar_width: 6,
  bar_gap: 2,
  bar_style: %Style{fg: :cyan},
  value_style: %Style{fg: :white, modifiers: [:bold]},
  label_style: %Style{fg: :dark_gray},
  direction: :vertical,                 # or :horizontal
  block: %Block{title: " Traffic ", borders: [:all]}
}

Values must be non-negative integers — floats or negatives raise ArgumentError at encode time.

Grouped bars

To render side-by-side clusters with shared captions — handy for comparing the same metric across categories (months, regions, products) — pass %BarGroup{} entries via :groups instead of :data. Each group carries its own optional :label and a list of %Bar{} structs, and :group_gap controls the spacing between clusters.

alias ExRatatui.Widgets.{Bar, BarChart, BarGroup}

%BarChart{
  groups: [
    %BarGroup{label: "Q1", bars: [%Bar{label: "A", value: 10}, %Bar{label: "B", value: 20}]},
    %BarGroup{label: "Q2", bars: [%Bar{label: "A", value: 15}, %Bar{label: "B", value: 25}]}
  ],
  bar_width: 3,
  bar_gap: 1,
  group_gap: 3,
  max: 30
}

Set either :data or :groups, not both. When :data is used, the chart renders as a single anonymous group; supplying :groups overrides it. :group_gap must be a non-negative integer, and each entry in :groups must be a %BarGroup{} whose :label is nil or a binary — anything else raises ArgumentError at encode time.

Sparkline

Compact, single-line bar chart for time-series or streaming data. :data is a list of non-negative integers with nil entries representing missing samples. Pick a preset via :bar_set (:nine_levels for smooth gradients, :three_levels for low-density glyphs) or pass a custom list of strings — the symbols are proportionally mapped across the nine internal density slots so any non-empty list works.

alias ExRatatui.Widgets.Sparkline

%Sparkline{
  data: [0, 1, 3, 5, 8, 3, 1, nil, 2, 4],
  max: 8,                                   # auto-scales when nil
  direction: :left_to_right,                # or :right_to_left
  bar_set: :nine_levels,                    # or :three_levels, or [" ", "▂", "▅", "█"]
  style: %Style{fg: :cyan},
  absent_value_symbol: "·",
  absent_value_style: %Style{fg: :dark_gray},
  block: %Block{title: " CPU ", borders: [:all]}
}

Entries must be non-negative integers or nil — floats, negatives, and non-list :data raise ArgumentError at encode time. Unknown directions, unknown bar-set atoms, empty custom lists, and non-integer :max values raise similarly.

Calendar

A monthly calendar grid that highlights a target date and optional per-day events. :display_date drives which month is rendered; events can be passed as a list of {Date, Style} tuples or as a %{Date => Style} map (map entries with a nil value are skipped, making toggling easy).

alias ExRatatui.Widgets.Calendar

%Calendar{
  display_date: ~D[2026-03-15],
  events: [
    {~D[2026-03-10], %Style{fg: :red, modifiers: [:bold]}},
    {~D[2026-03-20], %Style{fg: :green}}
  ],
  default_style: %Style{fg: :white},
  show_month_header: true,
  header_style: %Style{fg: :yellow, modifiers: [:bold]},
  show_weekdays_header: true,
  weekday_style: %Style{fg: :cyan},
  show_surrounding: %Style{fg: :dark_gray},
  block: %Block{title: " March ", borders: [:all]}
}

:display_date must be a %Date{}; :show_month_header and :show_weekdays_header must be booleans; event entries must be {%Date{}, %Style{}} tuples. Anything else raises ArgumentError at encode time. Set :show_surrounding to a Style to bleed the previous/next month into empty grid cells (leave it nil to hide them). The widget needs roughly 22 columns × 8 rows without a block, or 24 × 10 with one.

Canvas

A 2D drawing surface for plotting shapes, charts, and custom visualizations. Shapes are drawn onto a virtual coordinate system defined by :x_bounds and :y_bounds (both {min, max} tuples), then sampled onto the terminal cells using the chosen :marker.

alias ExRatatui.Widgets.Canvas
alias ExRatatui.Widgets.Canvas.{Circle, Label, Line, Points, Rectangle}
alias ExRatatui.Widgets.Canvas.Map, as: CanvasMap

%Canvas{
  x_bounds: {0.0, 100.0},
  y_bounds: {0.0, 50.0},
  marker: :braille,                          # or :dot, :block, :bar, :half_block
  background_color: :black,
  shapes: [
    %Line{x1: 0.0, y1: 0.0, x2: 100.0, y2: 50.0, color: :cyan},
    %Rectangle{x: 10.0, y: 10.0, width: 30.0, height: 20.0, color: :yellow},
    %Circle{x: 70.0, y: 25.0, radius: 10.0, color: :magenta},
    %Points{coords: [{20.0, 40.0}, {50.0, 30.0}, {80.0, 10.0}], color: :green},
    %Label{x: 70.0, y: 25.0, text: "★", color: :white}
  ],
  block: %Block{title: " Plot ", borders: [:all]}
}

Every shape takes a plain Color.t() (not a Style) — canvases sample individual pixels so text modifiers do not apply. Rectangle is drawn as an outline anchored at its bottom-left corner; Circle is drawn as an outline centered on {x, y}; Points accepts a list of {x, y} tuples; Label writes a styled text annotation at the given canvas-space coordinate (handy for naming peaks, marking origins, or labeling map locations). Bounds must be {min, max} tuples with min <= max; width, height, and radius must be non-negative; any required field set to nil or a mistyped value raises ArgumentError at encode time. :marker defaults to :braille, which gives the finest sub-cell resolution — drop to :dot or :block for lower-density output or for terminals without Braille fonts.

Drawing a world map

%CanvasMap{} paints the world's coastlines into the canvas — pair it with the geographic bounds {-180, 180} × {-90, 90} and the :dot or :braille marker. Label shapes layered on top let you pin city names or other annotations directly in lat/lon space.

%Canvas{
  x_bounds: {-180.0, 180.0},
  y_bounds: {-90.0, 90.0},
  marker: :dot,
  shapes: [
    %CanvasMap{resolution: :high, color: :green},  # :low | :high
    %Label{x: -74.0, y: 40.7, text: "NYC", color: :yellow},
    %Label{x: 139.7, y: 35.7, text: "Tokyo", color: :yellow}
  ],
  block: %Block{title: " World ", borders: [:all]}
}

Map.resolution accepts :low (cheap silhouette) or :high (richer coastline detail). Label.text must be a binary; the color applies as the text foreground. Both shapes raise ArgumentError if a required field is missing or mistyped.

Chart

An x/y line, scatter, or bar chart with axes, labels, legend, and multi-series support. Each %Dataset{} carries a list of {x, y} tuples (integers or floats) plus its own :marker, :graph_type, and :style. The required :x_axis and :y_axis configure the visible coordinate range via {min, max} :bounds and optional tick :labels. Pass nil as :legend_position to hide the legend entirely.

alias ExRatatui.Widgets.Chart
alias ExRatatui.Widgets.Chart.{Axis, Dataset}

%Chart{
  datasets: [
    %Dataset{
      name: "CPU",
      data: [{0.0, 12.0}, {1.0, 25.0}, {2.0, 48.0}, {3.0, 31.0}, {4.0, 19.0}],
      marker: :braille,                        # or :dot, :block, :bar, :half_block
      graph_type: :line,                       # or :scatter, :bar
      style: %Style{fg: :cyan}
    },
    %Dataset{
      name: "Memory",
      data: [{0.0, 40.0}, {1.0, 42.0}, {2.0, 55.0}, {3.0, 60.0}, {4.0, 58.0}],
      marker: :dot,
      style: %Style{fg: :magenta}
    }
  ],
  x_axis: %Axis{
    title: "Time (s)",
    bounds: {0.0, 4.0},
    labels: ["0", "2", "4"],
    style: %Style{fg: :dark_gray}
  },
  y_axis: %Axis{
    title: "Usage %",
    bounds: {0.0, 100.0},
    labels: ["0", "50", "100"],
    style: %Style{fg: :dark_gray}
  },
  legend_position: :top_right,                 # or :top, :top_left, :bottom, :bottom_left,
                                               # :bottom_right, :left, :right, or nil to hide
  hidden_legend_constraints: {{:ratio, 1, 4}, {:ratio, 1, 4}},
  block: %Block{title: " Metrics ", borders: [:all]}
}

:hidden_legend_constraints takes a {width_constraint, height_constraint} tuple — the same shapes accepted by ExRatatui.Layout (:length, :percentage, :ratio, :min, :max, :fill). The legend is hidden whenever its rendered size would exceed those bounds against the chart area, which keeps things readable in cramped layouts. Each dataset's :graph_type is independent: combine a :line series with a :scatter overlay in the same chart for emphasis.

Datasets with non-tuple data points, non-numeric coordinates, unknown markers, unknown :graph_types, unknown :legend_positions, missing axes, malformed :bounds, malformed :hidden_legend_constraints, and unknown :labels_alignment values raise ArgumentError at the bridge boundary.

Tabs

A tab bar for switching between views.

%Tabs{
  titles: ["Home", "Settings", "Help"],
  selected: 0,
  highlight_style: %Style{fg: :cyan, modifiers: [:bold]},
  divider: " | ",
  block: %Block{borders: [:all]}
}

Scrollbar

A scroll position indicator for long content, supporting both vertical and horizontal orientations.

%Scrollbar{
  content_length: 100,
  position: 25,
  viewport_content_length: 10,
  orientation: :vertical_right,
  thumb_style: %Style{fg: :cyan}
}

Orientations: :vertical_right, :vertical_left, :horizontal_bottom, :horizontal_top.

Checkbox

A boolean toggle with customizable checked and unchecked symbols.

%Checkbox{
  label: "Enable notifications",
  checked: true,
  checked_style: %Style{fg: :green},
  checked_symbol: "✓",
  unchecked_symbol: "✗"
}

TextInput

A single-line text input with cursor navigation and viewport scrolling. This is a stateful widget — its state lives in Rust via ResourceArc.

# Create state (once, e.g. in mount/1 or init/1)
state = ExRatatui.text_input_new()

# Forward key events
ExRatatui.text_input_handle_key(state, "h")
ExRatatui.text_input_handle_key(state, "i")

# Read/set value
ExRatatui.text_input_get_value(state)  #=> "hi"
ExRatatui.text_input_set_value(state, "hello")

# Render
%TextInput{
  state: state,
  style: %Style{fg: :white},
  cursor_style: %Style{fg: :black, bg: :white},
  placeholder: "Type here...",
  placeholder_style: %Style{fg: :dark_gray},
  block: %Block{title: "Search", borders: [:all], border_type: :rounded}
}

Clear

Resets all cells in its area to empty space characters. This is useful for clearing a region before rendering an overlay on top of existing content.

%Clear{}

Markdown

Renders markdown text with syntax-highlighted code blocks, powered by tui-markdown (pulldown-cmark + syntect). Supports headings, bold, italic, inline code, fenced code blocks, bullet lists, links, and horizontal rules.

%Markdown{
  content: "# Hello\n\nSome **bold** text and `inline code`.\n\n```elixir\nIO.puts(\"hi\")\n```",
  wrap: true,
  block: %Block{title: "Response", borders: [:all]}
}

Textarea

A multiline text editor with undo/redo, cursor movement, and Emacs-style shortcuts. This is a stateful widget — its state lives in Rust via ResourceArc.

# Create state (once, e.g. in mount/1 or init/1)
state = ExRatatui.textarea_new()

# Forward key events (with modifier support)
ExRatatui.textarea_handle_key(state, "h", [])
ExRatatui.textarea_handle_key(state, "enter", [])
ExRatatui.textarea_handle_key(state, "w", ["ctrl"])  # delete word backward

# Read value
ExRatatui.textarea_get_value(state)  #=> "h\n"

# Render
%Textarea{
  state: state,
  placeholder: "Type your message...",
  placeholder_style: %Style{fg: :dark_gray},
  block: %Block{title: "Message", borders: [:all], border_type: :rounded}
}

Throbber

A loading spinner that animates through symbol sets. The caller controls the animation by incrementing :step on each tick.

%Throbber{
  label: "Loading...",
  step: state.tick,
  throbber_set: :braille,
  throbber_style: %Style{fg: :cyan},
  block: %Block{title: "Status", borders: [:all]}
}

Available sets: :braille, :dots, :ascii, :vertical_block, :horizontal_block, :arrow, :clock, :box_drawing, :quadrant_block, :white_square, :white_circle, :black_circle.

A centered modal overlay that renders any widget over the parent area, clearing the background underneath. Useful for dialogs, confirmations, and command palettes.

%Popup{
  content: %Paragraph{text: "Are you sure?"},
  block: %Block{title: "Confirm", borders: [:all], border_type: :rounded},
  percent_width: 50,
  percent_height: 30
}

WidgetList

A vertical list of heterogeneous widgets with optional selection and scrolling. Each item is a {widget, height} tuple, making it ideal for chat message histories and similar layouts where items have different heights.

scroll_offset is a row offset from the top of the content, not an item index. To scroll to a specific item, sum the heights of all preceding items. Items partially above the viewport are clipped row-by-row instead of being dropped entirely.

Migrating from v0.6.1 or earlier: scroll_offset used to be an item index. If you were passing scroll_offset: selected, convert by summing the heights of all preceding items, e.g. items |> Enum.take(selected) |> Enum.map(&elem(&1, 1)) |> Enum.sum(). See the v0.6.2 entry in the CHANGELOG for details.

%WidgetList{
  items: [
    {%Paragraph{text: "User: Hello!"}, 1},
    {%Markdown{content: "**Bot:** Hi there!\n\nHow can I help?"}, 4},
    {%Paragraph{text: "User: What is Elixir?"}, 1}
  ],
  selected: 1,
  highlight_style: %Style{fg: :yellow},
  scroll_offset: 0,
  block: %Block{title: "Chat", borders: [:all]}
}

SlashCommands

SlashCommands is a utility module (not a widget struct) that helps you build a command palette on top of Popup + List. Use parse/1 to detect a /prefix, match_commands/2 to filter your commands, and render_autocomplete/2 to build the popup widgets you append to your render list.

alias ExRatatui.Widgets.SlashCommands
alias ExRatatui.Widgets.SlashCommands.Command

commands = [
  %Command{name: "help", description: "Show help"},
  %Command{name: "quit", description: "Exit the app"}
]

# In your render/2:
case SlashCommands.parse(input_text) do
  {:command, prefix} ->
    matched = SlashCommands.match_commands(commands, prefix)
    popup_widgets = SlashCommands.render_autocomplete(matched, area: area, selected: 0)
    base_widgets ++ popup_widgets

  :no_command ->
    base_widgets
end

See examples/chat_interface.exs for a full integration.

Focus management

Apps with multiple interactive widgets (e.g., a TextInput + List + details pane) need to track which widget "owns" the current keystroke. Rather than reinventing that bookkeeping every time, use ExRatatui.Focus:

alias ExRatatui.{Event, Focus}

# Declare the focus ring up front, e.g. in mount/2 or init/1.
state = %{
  focus: Focus.new([:search, :results, :details]),
  search: ExRatatui.text_input_new(),
  results: [...],
  selected: 0
}

Route every key event through Focus.handle_key/2 before dispatching. Tab / Shift+Tab / back_tab are consumed (focus moves, you get nil back). Everything else passes through unchanged.

def handle_event(%Event.Key{} = key, state) do
  {focus, key} = Focus.handle_key(state.focus, key)
  state = %{state | focus: focus}

  case key do
    nil ->
      state

    key ->
      case Focus.current(focus) do
        :search  -> update_search(state, key)
        :results -> update_results(state, key)
        :details -> update_details(state, key)
      end
  end
end

Style the focused widget with Focus.focused?/2:

border_style =
  if Focus.focused?(focus, :search),
    do: %Style{fg: :yellow},
    else: %Style{fg: :gray}

%TextInput{
  state: state.search,
  block: %Block{borders: [:all], border_style: border_style}
}

Override the default keys with %Event.Key{} entries — e.g., to add Ctrl+Tab / Ctrl+Shift+Tab or arrow-based cycling:

Focus.new([:search, :results, :details],
  next_keys: [%Event.Key{code: "tab"}, %Event.Key{code: "right", modifiers: ["ctrl"]}],
  prev_keys: [%Event.Key{code: "back_tab"}, %Event.Key{code: "left", modifiers: ["ctrl"]}]
)

See examples/focus_multi_panel.exs for a full three-panel demo.

Examples