#+TITLE: Quickshell Tutorial - Build Your Own Bar #+AUTHOR: Tony, btw #+date: 2025-12-03 #+HUGO_TITLE: Quickshell Tutorial - Build Your Own Bar #+HUGO_FRONT_MATTER_FORMAT: yaml #+HUGO_CUSTOM_FRONT_MATTER: :image "/img/quickshell.png" :showTableOfContents true #+HUGO_BASE_DIR: ~/repos/tonybtw.com #+HUGO_SECTION: tutorial/quickshell #+EXPORT_FILE_NAME: index #+OPTIONS: toc:nil broken-links:mark #+HUGO_AUTO_SET_HEADLINE_SECTION: nil #+DESCRIPTION: This is a quick and painless tutorial on how to build a custom status bar using Quickshell, a powerful Qt/QML based shell framework for Wayland. * Intro What's up guys, my name is Tony, and today I'm gonna give you a quick and painless introduction to Quickshell. Quickshell is a full shell framework built on Qt/QML. You can build pretty much any desktop widget you can imagine with it - bars, dashboards, wallpaper managers, screen lock widgets, and more. We're going to do a basic overview of how to create a bar using Quickshell today. In future tutorials I'll cover creating a wallpaper manager, building a dashboard overlay, and making a screen lock widget. But for now, let's start with the fundamentals by building a functional status bar step by step. * Install Quickshell Alright so I'm on NixOS today, but this is going to work on Arch, Gentoo, and other distributions. I'll leave install instructions for all of those below. [[https://quickshell.org/docs/v0.2.1/types/][Quickshell Documentation]] *** NixOS #+begin_src nix { environment.systemPackages = with pkgs; [ quickshell ]; } #+end_src *** Arch Linux #+begin_src sh yay -S quickshell-git #+end_src *** Gentoo For Gentoo, you'll need to compile from source: #+begin_src sh git clone https://github.com/outfoxxed/quickshell cd quickshell # Follow the build instructions in their README #+end_src * Running Examples You can run any of these examples with: #+begin_src sh qs -p ~/.config/testshell/01-hello.qml #+end_src Just swap out the filename as we go through each one. * 01 - Hello World Quickshell has excellent documentation, and we're going to be following that today. Let's start with the absolute basics - just getting something on screen. #+begin_src qml import Quickshell import QtQuick FloatingWindow { visible: true width: 200 height: 100 Text { anchors.centerIn: parent text: "Hello, Quickshell!" color: "#0db9d7" font.pixelSize: 18 } } #+end_src So what's going on here? Every Quickshell config starts with imports. We're pulling in =Quickshell= for the core stuff and =QtQuick= for basic UI elements like =Text= and =Rectangle=. We're using =FloatingWindow= as our root element. This is just a regular floating window - it doesn't dock to any edges or reserve any screen space. We're setting it to 200x100 pixels and making it visible. The =Text= element is pretty self-explanatory. The =anchors.centerIn: parent= bit is QML's layout system - it just centers the text inside its parent container. * 02 - Empty Bar Alright, let's turn this into an actual bar that docks to the top of your screen. #+begin_src qml import Quickshell import Quickshell.Wayland import QtQuick PanelWindow { anchors.top: true anchors.left: true anchors.right: true implicitHeight: 30 color: "#1a1b26" Text { anchors.centerIn: parent text: "My First Bar" color: "#a9b1d6" font.pixelSize: 14 } } #+end_src The big change here is we're using =PanelWindow= as our root element instead of =FloatingWindow=. This is a Wayland-specific thing (hence the new import), and it lets us dock the window to screen edges. Setting =anchors.top=, =anchors.left=, and =anchors.right= to =true= tells it to stick to the top edge and span the full width. The =implicitHeight: 30= gives us a 30 pixel tall bar. Unlike a floating window, a =PanelWindow= actually reserves space - your other windows won't overlap with it. * 03 - Workspaces So we are on Hyprland today, lets add quickshell to our Hyprland config. Now let's add some actual functionality - workspace indicators that show which workspace you're on and let you click to switch. #+begin_src qml import Quickshell import Quickshell.Wayland import Quickshell.Hyprland import QtQuick import QtQuick.Layouts PanelWindow { anchors.top: true anchors.left: true anchors.right: true implicitHeight: 30 color: "#1a1b26" RowLayout { anchors.fill: parent anchors.margins: 8 Repeater { model: 9 Text { property var ws: Hyprland.workspaces.values.find(w => w.id === index + 1) property bool isActive: Hyprland.focusedWorkspace?.id === (index + 1) text: index + 1 color: isActive ? "#0db9d7" : (ws ? "#7aa2f7" : "#444b6a") font { pixelSize: 14; bold: true } MouseArea { anchors.fill: parent onClicked: Hyprland.dispatch("workspace " + (index + 1)) } } } Item { Layout.fillWidth: true } } } #+end_src Okay, there's a lot more going on here. Let me break it down. We're importing =Quickshell.Hyprland= which gives us access to Hyprland's IPC. =QtQuick.Layouts= gives us =RowLayout=, which arranges its children horizontally. Way easier than manually positioning everything. The =Repeater= is super useful - it takes a model (in this case, just the number 9) and creates that many copies of whatever's inside it. Each copy gets an =index= variable (0-8). For each workspace number, we're looking up the actual workspace from the window manager with =Hyprland.workspaces.values.find()=. This gives us live data - when workspaces change, the bar updates automatically. We also check if it's the active workspace using =Hyprland.focusedWorkspace=. The color logic is straightforward: cyan if it's the active workspace, blue if it exists but isn't active, and muted gray if there's no windows on that workspace. The =MouseArea= makes the whole thing clickable, and =Hyprland.dispatch()= sends commands to Hyprland. So clicking on "3" runs =workspace 3=. That =Item { Layout.fillWidth: true }= at the end is just a spacer - it pushes everything to the left. * 04 - System Stats Now let's add some system stats. This is where things get interesting because we need to run shell commands and parse their output. ** Theme Properties Instead of hardcoding colors everywhere, we define them once on the PanelWindow. Now you can reference =root.colBg= anywhere in your config, and if you want to change your color scheme, you only have to do it in one place. #+begin_src qml PanelWindow { id: root property color colBg: "#1a1b26" property color colFg: "#a9b1d6" property color colMuted: "#444b6a" property color colCyan: "#0db9d7" property color colBlue: "#7aa2f7" property color colYellow: "#e0af68" property string fontFamily: "JetBrainsMono Nerd Font" property int fontSize: 14 } #+end_src ** Running Shell Commands This is how you run external commands and capture their output. We import =Quickshell.Io= which gives us the =Process= type. #+begin_src qml Process { id: cpuProc command: ["sh", "-c", "head -1 /proc/stat"] stdout: SplitParser { onRead: data => { if (!data) return var p = data.trim().split(/\s+/) var idle = parseInt(p[4]) + parseInt(p[5]) var total = p.slice(1, 8).reduce((a, b) => a + parseInt(b), 0) if (lastCpuTotal > 0) { cpuUsage = Math.round(100 * (1 - (idle - lastCpuIdle) / (total - lastCpuTotal))) } lastCpuTotal = total lastCpuIdle = idle } } Component.onCompleted: running = true } #+end_src The =command= is an array - first element is the program, rest are arguments. The =SplitParser= attached to =stdout= calls =onRead= for each line of output. Setting =running = true= triggers the process to run. ** Timers To update the CPU usage periodically, we use a =Timer=. This one fires every 2 seconds and re-runs the CPU process. #+begin_src qml Timer { interval: 2000 // Every 2 seconds running: true // Start immediately repeat: true // Keep going forever onTriggered: cpuProc.running = true } #+end_src * 05 - Adding Widgets Let's expand our bar with a clock and memory usage. This builds on the same patterns - more =Process= calls and =Timer= elements. ** Memory Widget #+begin_src qml // Add to your system data properties property int memUsage: 0 // Memory process Process { id: memProc command: ["sh", "-c", "free | grep Mem"] stdout: SplitParser { onRead: data => { if (!data) return var parts = data.trim().split(/\s+/) var total = parseInt(parts[1]) || 1 var used = parseInt(parts[2]) || 0 memUsage = Math.round(100 * used / total) } } Component.onCompleted: running = true } // Update your timer to run both processes Timer { interval: 2000 running: true repeat: true onTriggered: { cpuProc.running = true memProc.running = true } } #+end_src ** Clock The clock is just a =Text= element with its own timer. Every second it updates the text with the current time. #+begin_src qml Text { id: clock text: Qt.formatDateTime(new Date(), "ddd, MMM dd - HH:mm") Timer { interval: 1000 running: true repeat: true onTriggered: clock.text = Qt.formatDateTime(new Date(), "ddd, MMM dd - HH:mm") } } #+end_src ** Adding Dividers To visually separate widgets, you can use simple =Rectangle= elements: #+begin_src qml Rectangle { width: 1; height: 16; color: root.colMuted } #+end_src * Complete Bar Example Here's the final bar with workspaces, CPU, memory, and a clock all together: #+begin_src qml import Quickshell import Quickshell.Wayland import Quickshell.Io import Quickshell.Hyprland import QtQuick import QtQuick.Layouts PanelWindow { id: root // Theme property color colBg: "#1a1b26" property color colFg: "#a9b1d6" property color colMuted: "#444b6a" property color colCyan: "#0db9d7" property color colBlue: "#7aa2f7" property color colYellow: "#e0af68" property string fontFamily: "JetBrainsMono Nerd Font" property int fontSize: 14 // System data property int cpuUsage: 0 property int memUsage: 0 property var lastCpuIdle: 0 property var lastCpuTotal: 0 // Processes and timers here... anchors.top: true anchors.left: true anchors.right: true implicitHeight: 30 color: root.colBg RowLayout { anchors.fill: parent anchors.margins: 8 spacing: 8 // Workspaces Repeater { model: 9 Text { property var ws: Hyprland.workspaces.values.find(w => w.id === index + 1) property bool isActive: Hyprland.focusedWorkspace?.id === (index + 1) text: index + 1 color: isActive ? root.colCyan : (ws ? root.colBlue : root.colMuted) font { family: root.fontFamily; pixelSize: root.fontSize; bold: true } MouseArea { anchors.fill: parent onClicked: Hyprland.dispatch("workspace " + (index + 1)) } } } Item { Layout.fillWidth: true } // CPU Text { text: "CPU: " + cpuUsage + "%" color: root.colYellow font { family: root.fontFamily; pixelSize: root.fontSize; bold: true } } Rectangle { width: 1; height: 16; color: root.colMuted } // Memory Text { text: "Mem: " + memUsage + "%" color: root.colCyan font { family: root.fontFamily; pixelSize: root.fontSize; bold: true } } Rectangle { width: 1; height: 16; color: root.colMuted } // Clock Text { id: clock color: root.colBlue font { family: root.fontFamily; pixelSize: root.fontSize; bold: true } text: Qt.formatDateTime(new Date(), "ddd, MMM dd - HH:mm") Timer { interval: 1000 running: true repeat: true onTriggered: clock.text = Qt.formatDateTime(new Date(), "ddd, MMM dd - HH:mm") } } } } #+end_src * Key Concepts Summary | Concept | Description | |---------------+-------------------------------------------------------| | FloatingWindow | A regular floating window, doesn't dock | | PanelWindow | Docks to screen edges, reserves space | | RowLayout | Arranges children horizontally | | Repeater | Creates multiple copies of a component | | Process | Runs shell commands and captures output | | Timer | Triggers actions at intervals | | MouseArea | Makes elements clickable | | anchors | QML's layout system for positioning | | property | Declare custom variables on components | * Next Steps That's the core of it. Quickshell can do way more than just bars though - you can build: - Wallpaper managers with smooth transitions - Dashboard overlays with system stats - Screen lock widgets - Notification centers - Application launchers Check out the Quickshell documentation for more advanced features and the full API reference. * Final Thoughts Thanks so much for checking out this tutorial. If you got value from it, and you want to find more tutorials like this, check out my youtube channel here: [[https://youtube.com/@tony-btw][YouTube]], or my website here: [[https://www.tonybtw.com][tony,btw]] You can support me here: [[https://ko-fi.com/tonybtw][kofi]]