A fast, compile-time-configured status bar for i3 and sway, outputting the i3bar JSON protocol.
The goal is to avoid the fork-and-exec overhead of tools like i3blocks for every
update cycle. Modules that read system state (CPU, WiFi, battery, network traffic)
are compiled in and run natively. A shell module is available when you need to
run an external command, but it is opt-in.
Building
Requires Zig 0.16. A shell.nix is provided that pins the correct version:
nix-shell --run "zig build"The binary is placed at zig-out/bin/hardbar.
Setup
In your i3 or sway config:
bar {
status_command /path/to/hardbar
}
Configuration
Configuration lives in config.zon, next to build.zig. It is a ZON expression
evaluated at compile time — there is no runtime config file parsing.
Minimal example
.{
.global = .{
.separator = " | ",
},
.status = .{
.{
.module = "cpu",
.interval = 5,
.format = .{ "CPU: ", .usage },
},
.{
.module = "datetime",
.interval = 10,
.format = "%Y-%m-%d %H:%M",
},
},
}
Global settings
| Field | Default | Description |
|---|---|---|
.separator |
" | " |
String placed between blocks |
Block settings
Every entry in .status supports these common fields:
| Field | Required | Description |
|---|---|---|
.module |
yes | Module name (see below) |
.interval |
no (5s) | Seconds between updates |
.format |
no | Format string or tuple (module-specific) |
.pango |
no | Set true to enable Pango markup in this block |
.args |
no | Module-specific arguments struct |
Blocks that produce empty output are omitted from the bar entirely. This is
useful for modules like battery that should be invisible on desktop machines.
Click handling
Any block can have click/scroll commands. When the user interacts with a block
in i3bar, the corresponding shell command is run via /bin/sh -c.
| Field | Mouse event |
|---|---|
.click_left |
Left click (button 1) |
.click |
Alias for .click_left |
.click_middle |
Middle click (button 2) |
.click_right |
Right click (button 3) |
.scroll_up |
Scroll wheel up (4) |
.scroll_down |
Scroll wheel down (5) |
.scroll_left |
Scroll wheel left (6) |
.scroll_right |
Scroll wheel right (7) |
.click_back |
Back button (8) |
.click_forward |
Forward button (9) |
Pango markup
Set .pango = true on a block to enable Pango markup in its output. The block
will be sent to i3bar with "markup":"pango". Modules that produce text from
external sources (wifi SSID, weather, shell output) XML-escape their output when
pango mode is active, so that the literal text is still displayed safely inside
any surrounding markup you add to the format string.
.{
.module = "battery",
.pango = true,
.format = .{
.discharging = .{ "<span color=\"#ff0000\">", .capacity, "%</span>" },
.charging = .{ .capacity, "% +" },
},
},
Modules
cpu
Shows CPU usage as a percentage, averaged across all cores since the last sample.
Reads /proc/stat.
Format fields: .usage — integer percentage with % sign (e.g. 42%)
Args: none
Default format: .{.usage}
.{
.module = "cpu",
.interval = 5,
.format = .{ "CPU: ", .usage },
},
The first sample always shows 0% because there is no previous baseline.
datetime
Shows the current local date and time. The format string is passed directly to
libc strftime(3).
Format: a single string (not a tuple), passed to strftime
Args: none
Default format: "%Y-%m-%d %H:%M:%S"
.{
.module = "datetime",
.interval = 10,
.format = "%Y-%m-%d %H:%M",
},
Common strftime codes: %Y year, %m month, %d day, %H hour (24h),
%M minute, %S second, %A weekday name, %I hour (12h), %p AM/PM.
wifi
Shows the SSID and signal strength of the associated WiFi network. Uses the Generic Netlink / nl80211 interface directly (no external tools needed).
The interface is auto-detected at runtime from /proc/net/wireless on first
use and cached for the lifetime of the process. Override with .args if needed.
Format fields:
| Field | Description |
|---|---|
.ssid |
Network name (XML-escaped if pango) |
.signal |
Signal strength in dBm (e.g. -61) |
Args:
| Field | Default | Description |
|---|---|---|
.interface |
auto-detected | WiFi interface name |
Default format: .{.ssid}
.{
.module = "wifi",
.interval = 5,
.format = .{ .ssid, " (", .signal, "dBm)" },
},
// Override interface:
.{
.module = "wifi",
.interval = 5,
.args = .{ .interface = "wlan0" },
},
When not associated, the block shows disconnected.
battery
Shows battery capacity and charge status. Reads
/sys/class/power_supply/<battery>/.
The block produces empty output (and is hidden) when the battery path cannot be read — so on desktop machines without a battery it disappears automatically.
Format: a struct with per-status sub-formats. Each sub-format is a tuple.
| Key | When used |
|---|---|
.discharging |
Battery discharging or status unknown |
.charging |
Battery charging |
.full |
Battery full (falls back to .charging if absent) |
Format fields (within each sub-format):
| Field | Description |
|---|---|
.capacity |
Integer percent (0–100) |
Args:
| Field | Default | Description |
|---|---|---|
.battery |
"BAT0" |
Battery name under /sys/class/power_supply/ |
Default format:
.{
.discharging = .{ .capacity, "%" },
.charging = .{ .capacity, "% +" },
}
.{
.module = "battery",
.interval = 30,
.pango = true,
.format = .{
.discharging = .{ "<span color=\"#ff0000\">", .capacity, "%</span>" },
.charging = .{ .capacity, "% +" },
.full = .{ .capacity, "% (full)" },
},
},
nettraffic
Shows network throughput in IEC units (B/s, K/s, M/s, G/s). Reads byte counters
from /sys/class/net/<iface>/statistics/. The first interval shows nothing while
a baseline is being established.
The interface is auto-detected from the default route in /proc/net/route
(excludes ppp*, tun*, tap*).
Format fields:
| Field | Description |
|---|---|
.rx |
Download rate (e.g. 1.4M) |
.tx |
Upload rate |
.total |
Combined rx + tx rate |
.iface |
Interface name (e.g. eth0) |
Args:
| Field | Default | Description |
|---|---|---|
.interface |
auto-detected | Network interface name |
Default format: .{ .rx, "↓ ", .tx, "↑" }
.{
.module = "nettraffic",
.interval = 5,
},
// Fixed interface with combined rate:
.{
.module = "nettraffic",
.interval = 5,
.format = .{ .iface, " ", .total, " total" },
.args = .{ .interface = "eth0" },
},
weather
Fetches current weather from wttr.in. Makes an HTTP request each interval. Minimum interval is 60 seconds (enforced at compile time).
Format: not a tuple — the .args.format field is a wttr.in format string
passed as a URL parameter. See https://wttr.in/:help for codes.
Args:
| Field | Default | Description |
|---|---|---|
.location |
"" |
City name or coordinates (URL-encoded) |
.format |
"%t+%C" |
wttr.in format string (%t=temp, %C=condition, %h=humidity, %w=wind) |
Common format codes: %t temperature, %C condition text, %h humidity,
%w wind, %p precipitation, %m moon phase.
.{
.module = "weather",
.interval = 1800,
.args = .{ .location = "New+York", .format = "%t+%C" },
},
.{
.module = "weather",
.interval = 900,
.args = .{ .location = "48.8566,2.3522", .format = "%t %h %w" },
},
pulse
Shows PulseAudio (or PipeWire's PulseAudio compatibility layer) sink volume and
mute state. Connects to the PA server directly via libpulse each interval.
Format: a struct with per-state sub-formats. Each sub-format is a tuple.
| Key | When used |
|---|---|
.unmuted |
Sink is not muted |
.muted |
Sink is muted |
Format fields (within each sub-format):
| Field | Description |
|---|---|
.volume |
Volume as integer percent (0–100) |
Args:
| Field | Default | Description |
|---|---|---|
.sink |
"@DEFAULT_SINK@" |
Sink name passed to PA |
Default format:
.{
.unmuted = .{ .volume, "%" },
.muted = .{ .volume, "% [muted]" },
}
.{
.module = "pulse",
.interval = 5,
.format = .{
.unmuted = .{ " ", .volume, "%" },
.muted = .{ " ", .volume, "%" },
},
.scroll_up = "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+",
.scroll_down = "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-",
.click_middle = "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle",
},
shell
Runs an arbitrary shell command each interval and displays the first line of its stdout, stripped of leading and trailing whitespace. If the command produces no output the block is hidden.
Format: a single string (not a tuple) — the shell command to run. It is
executed via /bin/sh -c. If .pango = true, the output is XML-escaped before
being placed in the bar (so you can safely display arbitrary text inside Pango
markup).
Args: none
.{
.module = "shell",
.interval = 5,
.format = "cat /proc/loadavg | cut -d' ' -f1-3",
},
.{
.module = "shell",
.interval = 30,
.format = "/home/user/scripts/my-status",
.click_left = "/home/user/scripts/my-status-click",
},
A module can be used more than once with different .format values.