ui-ux
Dynamic Island for Hyprland
A hardware-style notch for the Hyprland desktop built on AGS and GTK3, compact at rest and expanding on hover with media, synced lyrics, pomodoro, a drag-and-drop shelf, and prayer times.
- Role
- Desktop Shell & UI Engineering (GTK3 / AGS) + Integration & Installer
- Duration
- 19–28 October 2026
- Origin
- Built from scratch
- Date
- October 2026
Technologies
- gjs
- ags
- gtk3
- gtk-layer-shell
- cairo
- glib
- dbus
- mpris
- bluez
- hyprland
- python
- bash
Impact
28 feature modules and 6 core modules (5,805 lines of GJS across 34 files), 13 rendered pages driven by one state machine, 107 CSS classes across 956 lines, a 924-line idempotent installer, and a 179-line Python input harness, built over 13 commits.
Built from zero, with Nusa (rama2555.github.io/nusa) used as the motion and visual reference. Everything in the repository was written here: the geometry engine, every page, the theming, the notification pipeline, the persistence layer, and the installers.
Problem
Making a desktop widget behave like hardware looks easy until three things appear. First, the platform cannot animate what the design needs: GTK3 has no transform and does not animate width, height, or border-radius from CSS, so the honest result of a naive implementation is a card that stretches while it moves, a corner radius that snaps between 14 and 32, a closing animation that undershoots to 141×26 before popping back, and a navigation pill that overlaps the collapsed clock. Second, pointer input cannot be trusted as delivered: GTK3 emits leave-notify-event when the pointer merely crosses into a child that owns a GdkWindow, and this GJS build does not expose the event struct at all, so event.detail, event.button, and event.x are undefined; the notch then closes the moment the pointer touches its own content, and every arrow click silently misses. Third, the window does not survive the compositor: after a resume or a monitor hotplug, gtk-layer-shell turns zwlr_layer_surface_v1.closed into gtk_window_close(), GTK destroys the window, and visible = true can no longer revive it, while global Variable handlers keep firing at widgets that no longer exist.
The surface has its own hard constraint: it is a layer-shell window exactly as tall as the notch, so anything drawn outside the widget box, whether a shadow or a glow, is clipped, and the content must be shrinkable below its natural size. That single constraint is why the background is painted in Cairo instead of styled, and why the geometry engine, the hover rules, and the rebuild path could not be designed independently of one another.
Solution
Motion is a JavaScript engine, not CSS. A real cubic-bézier solver (bezierTween in spring.js, cubic-bezier(.34,1.32,.5,1), 620 ms) drives set_size_request every frame, with the corner radius interpolated in discrete steps and the concave “ears” redrawn per frame; closing values are clamped to the target so the curve’s overshoot never shrinks the notch past its goal. A width lock with 16px headroom stops the layer surface from resizing every frame, which otherwise swallows mouse presses, and the wrapper is a CenterBox rather than a Box, so the frame centre stays fixed to the pixel while the card contracts. The navigation space is subtracted from the content (stack.margin_bottom) and eased with the card height, so the collapsed clock never jumps.
Hover and keyboard are verified against the compositor instead of trusted. Every leave re-checks the real cursor position through hyprctl -j cursorpos against a cached monitor rectangle, guarded by a token so a stale answer cannot close a freshly entered island, plus a 100 ms watchdog for the padding strip where no follow-up event arrives. All pointer handling goes through one helper (modules/pointer.js) using event.get_button() and event.get_coords(), because the struct fields are unreadable. Arrow navigation is installed as a Hyprland submap of consuming binds, active only while the nav is visible, so the arrows never leak into the focused application.
The window owns a rebuild path: mountIsland() recreates the surface on destroy, a two-second watchdog and the logind resume signal check for a disposed window, and every connection to a global Variable is torn down in the destroy handler. Monitor mapping matches the Hyprland id to the GdkMonitor index by geometry, because they are not the same namespace.
Beyond motion, it is a real product: an MPRIS media card with a synced lyrics sub-view (LRCLIB over curl, LRC parsing, click-to-seek, and auto-scroll that yields for eight seconds on manual scroll), pomodoro, water intake, todo (text delegated to rofi, since the surface is keymode: none), a drag-and-drop shelf that stores references to files rather than copies, a Bluetooth detail page with a Cairo-drawn volume slider, and a prayer page that computes its times locally with PrayTimes so it works with no network, over a sky background painted in Cairo. Theming substitutes wallust tokens into CSS with semantic danger and ok overrides, plus a hexToRgb guard that rejects non-hex input instead of silently turning every accent black.
Integrity is treated as part of the design. Persistence writes JSON atomically through GLib.file_set_contents, debounced, and reports a failure to the user instead of losing data silently; the installer is idempotent and marker-based, always backing up to .dyn-island.bak, supporting –dry-run, and restarting waybar and AGS with their original command-line arguments, so it never rewrites user configuration without warning. Destructive shelf and todo actions are confirmable and undoable, and the notification popup is drawn by the island on its own overlay layer, with the slide animated through a manual scroll offset because negative layer margins, margin_end, and Revealer all fail to produce a slide on this stack.
Honest note: this project has no conventional automated test suite and no CI. Verification happens on the real compositor, because the expensive bugs here are visual, not logical: navigation fixes are driven through /dev/uinput and counted per attempt, hover and centring are read back with hyprctl cursorpos and hyprctl layers, and motion is checked by sampling geometry per frame rather than asserting on values. Known, documented limitations remain: an OSD on default-sink change is never triggered because AGS/Gvc does not emit speaker-changed for it, the swaync volume slider colour is not reachable from style.css, and the closed-state equalizer is decorative, not a real audio peak visualiser. The frontend here is GJS targeting GTK3, not a React or TypeScript codebase; the backend is 34 GJS source files and 5,805 lines, the stylesheet is 956 lines across 107 classes, and the work spans 13 commits from 19 to 28 October 2026.
Contact
Interested in working together?
Tell me what you are building and what you need. A short note is enough.