Ascii is ASCII-art UI components for Elixir, and animated art: the 191 pieces of ascii.rest, from file trees, gauges and htop-style meters that draw your own data to scenes and logos. A piece is a function of time: ask for its picture at t seconds and you get a frame, rows lines of cols characters, and for coloured pieces a palette colour for every cell. Everything else in the library, and every integration in these guides, is built on that: a component is a piece that takes your data as options (see Live data) and your colours (see Colouring parts).

Installation

Ascii has no required dependencies. Add Phoenix LiveView only if you want the Phoenix components.

# needs: a mix.exs to put it in
def deps do
  [
    {:ascii, "~> 0.3.0"},
    # optional:
    {:phoenix_live_view, "~> 1.1"}
  ]
end

Finding a piece

Pieces are named by their upstream slug. Ascii.meta!/1 says what a piece is, how big it is, how fast it plays and which options it takes.

Ascii.pieces() |> length()
#=> 191

Ascii.pieces(category: :logos) |> Enum.take(3)
#=> ["c", "clojure", "cpp"]

meta = Ascii.meta!("spinners")
{meta.cols, meta.rows, meta.fps}
#=> {66, 13, 12}

meta.options
#=> %{labels: true, names: [], speed: 1}

The fourteen categories are in Ascii.Meta.categories/0. Scenes are large coloured pictures (200×100 cells); logos and distros are coloured marks; everything else is text in one ink.

One frame

Ascii.render!/3 draws a single frame from a fresh start: the quickest way to put a picture somewhere.

IO.puts(Ascii.render!("donut", 1.5))

{:ok, text} = Ascii.render("big-text", 0.0, options: %{text: "elixir"})
String.split(text, "\n") |> length()
#=> 8

Frame 0 is designed upstream to be a good still: it is what a reader who prefers reduced motion sees. Ascii.still/2 draws it once and keeps it, for pieces at their default options: a scene takes up to a second to set up, and after the first time its still is there at once.

{:ok, frame} = Ascii.still("night-coast")
{frame.cols, frame.rows}
#=> {200, 100}

Playing a piece

Many pieces keep state between frames (a simulation steps by how far t moved since the last frame), so to play one, make an animation and move forward through time with Ascii.frame/3, keeping the animation each call returns:

{:ok, anim} = Ascii.new("matrix-rain")

{frames, _anim} =
  Enum.map_reduce(0..9, anim, fn i, anim ->
    Ascii.frame(anim, i / anim.fps)
  end)

Enum.map(frames, & &1.t) |> Enum.take(3)
#=> [0.0, 0.05, 0.1]

Or take frames from a lazy stream, one every 1/fps seconds:

Ascii.stream!("spinners", fps: 12)
|> Stream.map(& &1.text)
|> Enum.take(24)
|> length()
#=> 24

For real-time playback (a frame on screen every 1/fps of a second) use Ascii.Player; see the terminal guide.

Loops

Sixty pieces, every logo among them, close a loop: after meta.loop seconds their frames come round again exactly. Ascii.loop/1 draws one cycle, which you can then repeat for nothing rather than draw every frame anew:

Ascii.meta!("elixir").loop
#=> 5

{:ok, frames, fps} = Ascii.new!("elixir") |> Ascii.loop()
{length(frames), fps}
#=> {150, 30}

Ascii.new!("donut") |> Ascii.loop()
#=> :error

Options can change a period (a logo shines every shine seconds), so with anything but the defaults loop/1 draws a second cycle to check, and gives :error when it does not match. The LiveView player sends a loop to the browser once and is then idle; see the Phoenix guide.

What a frame holds

An Ascii.Frame is plain data, for any renderer:

  • text: the picture, lines joined by \n; lines: the same, as a list;
  • cols, rows: its size in cells; cell: a cell's height in cell widths (2, the shape of a character, or 1 for square-celled scenes);
  • colors: for a coloured piece drawn in colour, a binary of cols * rows bytes, one palette index a cell, row by row; otherwise nil;
  • palette: the #rrggbb colours those indices point into;
  • ground: the colour behind a scene, or nil;
  • t: the time it was drawn at.
{frame, _anim} = Ascii.new!("elixir") |> Ascii.frame(0.0)
byte_size(frame.colors) == frame.cols * frame.rows
#=> true

Ascii.Frame.color_at(frame, 20, 14)
#=> "#816a92"

Options

Each piece takes the options in its meta. Whatever you pass is checked against those defaults before the piece sees it: keys must be the piece's own, values must be of the same kind, numbers are clamped to sane bounds, and strings from params are cast. Nothing from outside ever becomes an atom, so it is safe to pass params straight through.

{:ok, _} = Ascii.new("typewriter", options: %{"prefix" => "we make ", "phrases" => ["tools."]})
{:ok, _} = Ascii.new("spinners", options: %{"speed" => "2"})

Ascii.new("typewriter", options: %{"evil" => 1})
#=> {:error, {:invalid_option, "evil"}}

Ascii.new("no-such-piece")
#=> {:error, :unknown_piece}

Live data

Most pieces are art, but the UI and data ones are also widgets: give them your own data and they draw it, for a dashboard, a status page or a terminal UI. Ascii.put_options/2 takes new options while a piece plays, checked and merged over its own, and pieces that can carry on from where they were (a gauge's needle swings to a new reading, bars ease to new values):

anim = Ascii.new!("gauge", options: %{label: "cpu", value: 35})
{frame, anim} = Ascii.frame(anim, 0.0)
frame.text =~ "35%"
#=> true

anim = Ascii.put_options!(anim, value: 80)
{frame, _anim} = Ascii.frame(anim, 0.5)
frame.text =~ "80%"
#=> true
pieceyour dataits size
progress-barvalues, percentages; labels, stylecols; two rows a bar
spinnersnames, the ones to showshrinks to fit them
sparklineseries, each with its values (to ±10^15; too wide, 17.5M)cols, rows
gaugevalue, with min, max, unitcols, rows
bar-chartdatasets, labels, titlecols
cpu-meterscores, memory, swap, load, tasks, uptime, processesgrows with cores
uptime-barhistory, daily uptime per servicedays
heatmapcounts, daily, up to a date; months, weekdaysweeks
candlestickscandles, [open, high, low, close]; times
equalizerlevels, 0 to 1 a band
radarcontacts, each a bearing and range; land
boot-logsteps, each text and status (ok, warn, fail, busy); titlecols, rows
terminalhistory, commands and their output; typingcols, rows
file-treepaths (or entries); with walk: false, open and cursorcols, rows
form-controlsfields (text, select, radio, checkbox, switch, slider), buttons, focus, statuscols; two rows a field
calendardate to show and select, marks; months, weekdayscols, rows
digital-clocktimer (down or up), from seconds, label

Each piece's documentation (Ascii.Pieces.Gauge and so on) has the details. In Phoenix, Ascii.Phoenix.live_player/1 takes new options from your assigns as they change; in a terminal, Ascii.Player.set_options/2. Lists of numbers are checked as numbers, so these can come from params as JSON, like any option. A piece whose size follows its options gives frames of that size: always read frame.cols and frame.rows.

{frame, _} =
  "progress-bar"
  |> Ascii.new!(options: %{values: [42], labels: ["copying"], cols: 40})
  |> Ascii.frame(0.0)

{frame.cols, frame.rows}
#=> {40, 3}

Keys and clicks

A component that can be used takes keys, by ExRatatui's names for them ("up", "enter", " ", a character), and clicks on its cells: a file tree with walk: false, a form of your fields, a calendar with a date. Ascii.handle_event/2 gives the piece the event, and it takes up what changes as new options, so what was chosen is there to read; an event it has no use for is :ignore.

calendar = Ascii.new!("calendar", options: %{date: "2026-10-08", time: false})
{:ok, calendar} = Ascii.handle_event(calendar, {:key, "right"})
{:ok, calendar} = Ascii.handle_event(calendar, {:key, "down"})
calendar.options.date
#=> "2026-10-16"

form = %{fields: [%{type: "text", label: "name"}, %{type: "switch", label: "notify"}]}
anim = Ascii.new!("form-controls", options: form)
anim = Enum.reduce(~w(a d a), anim, fn key, anim -> elem(Ascii.handle_event(anim, {:key, key}), 1) end)
hd(anim.options.fields).text
#=> "ada"

Ascii.handle_event(anim, {:key, "f1"})
#=> :ignore

Ascii.part_at/4 says what is under a cell: for a click, which file, which bar, which day. In Phoenix, a player with interactive takes the browser's keys and clicks (see Phoenix); in ExRatatui, Ascii.ExRatatui.handle_event/3 takes its events; in a terminal, mix ascii.play --interactive.

Words for screen readers

Each component tells what it shows from its data, for a screen reader or a caption; a piece that plays its own script says what it is. The Phoenix players and components label their picture with it, and Ascii.Render.SVG takes it as a title.

Ascii.new!("gauge", options: %{label: "load", value: 72}) |> Ascii.describe()
#=> "load: 72%"

steps = [%{text: "built", status: "ok"}, %{text: "migrating", status: "busy"}]
Ascii.new!("boot-log", options: %{title: "deploy", steps: steps}) |> Ascii.describe()
#=> "deploy: 2 steps, 1 ok, 1 running (migrating)"

Ascii.new!("donut") |> Ascii.describe()
#=> "donut: a lit torus turning on two axes, after donut.c"

Light pages and one ink

Shaded pieces read their character ramp from the other end on a light page, so dense characters still mean dark: pass paper: true. A coloured piece can be drawn as text in one ink with color: false, for places that cannot show colour.

{:ok, anim} = Ascii.new("donut", paper: true)
{:ok, mono} = Ascii.new("night-coast", color: false)
{frame, _} = Ascii.frame(mono, 0.0)
frame.colors
#=> nil

Colouring the ink

A piece in one ink, which is most of them, can be given its colour with :ink: one colour for every character, or several for a gradient across the picture, left to right unless :gradient says :vertical or :diagonal. Its frames then carry colours like a coloured piece's, so every renderer shows them in colour where it can (and as plain text in mode: :mono). The characters are exactly the piece's own; a coloured piece keeps its palette unless drawn with color: false. See Ascii.Ink.

{frame, _} = Ascii.new!("big-text", ink: ["#ff5f6d", "#ffc371"]) |> Ascii.frame(1.0)
{hd(frame.palette), List.last(frame.palette)}
#=> {"#ff5f6d", "#ffc371"}

{frame, _} = Ascii.new!("donut", ink: "#7fdbca") |> Ascii.frame(0.0)
frame.palette
#=> ["#7fdbca"]

On a page, one ink can be a CSS variable, so a piece takes your theme's colour and follows it into dark mode; a terminal draws the fallback, if it has one:

{frame, _} = Ascii.new!("donut", ink: "var(--accent, #7fdbca)") |> Ascii.frame(0.0)
frame.palette
#=> ["var(--accent, #7fdbca)"]

Colouring parts

Some pieces know what each of their cells is: a file tree's folders, files and branches, a boot log's OK and WARN, a meter's bars and readings. Their meta lists these parts, each with a colour it suggests, and :parts gives them colours: a map of names to colours, or true for the suggested ones. A name covers the names under it, so file colours every file and file.md only Markdown. Parts not given a colour take the :ink, or the renderer's own. See Ascii.Parts.

Ascii.meta!("boot-log").parts |> Enum.take(5)
#=> [{"title", nil}, {"rule", "#8b949e"}, {"label", nil}, {"detail", nil}, {"status.ok", "#3fb950"}]

{frame, _} =
  Ascii.new!("file-tree", parts: %{"folder" => "#58a6ff", "file.md" => "#d29922"})
  |> Ascii.frame(3.0)

frame.palette
#=> [nil, "#58a6ff", "#d29922"]

The nil is the renderer's own ink: a terminal's foreground, a page's colour. Parts' colours can be CSS variables too, and with paper: true the suggested colours are those for a light page:

{frame, _} = Ascii.new!("boot-log", parts: true, paper: true) |> Ascii.frame(0.0)
"#1a7f37" in frame.palette
#=> true

The type pieces (big-text, glitch, wave-text) know their letters, so a heading can be in your palette a letter at a time: parts: %{"letter.1" => "#ff5f6d", "letter.2" => "#ffc371"}, or "letter" for all of them.

Pieces that read the clock

analog-clock, digital-clock, calendar, sundial and landscape show the real time. They read the system clock in the system's time zone, or a DateTime you pass as :now, whose offset sets the local time:

now = DateTime.new!(~D[2026-03-14], ~T[15:09:26], "Etc/UTC")
text = Ascii.render!("digital-clock", 0.0, now: now)
text =~ "█"
#=> true

Cost

Most pieces draw a frame in well under a millisecond. Scenes are the heavy ones: up to about 1.2 seconds to set up (Ascii.new/2) and 2 to 14 ms a frame. Set a piece up once and keep the animation, rather than calling render/3 in a loop. mix ascii.perf times every piece on your machine.

Where next