Published on January 14, 2026 by Dominic Böttger · 8 min read
I’ve always wanted my computer to automatically switch between light and dark themes based on actual daylight - not arbitrary fixed times like “8 AM to 6 PM”. Living in Heidelberg, the sun sets at 4:30 PM in winter but stays up until 9 PM in summer. A fixed schedule just doesn’t work.
In this guide, I’ll show you how to set up truly intelligent auto dark mode on Linux that:
- Calculates real sunrise/sunset times for your GPS coordinates
- Automatically detects your location via IP geolocation
- Switches both your desktop theme AND screen color temperature
- Uses civil twilight for smooth transitions before actual sunrise/sunset
The End Result
Once configured, your system will automatically:
| Time of Day | Theme | Screen Temperature |
|---|---|---|
| After sunrise | Light theme (Catppuccin Latte) | 6000K (neutral daylight) |
| After sunset | Dark theme (Tokyo Night) | 4000K (warm, easy on eyes) |
The transition happens at civil twilight - about 30 minutes before sunrise and after sunset - giving you a smooth, natural transition that matches how your eyes perceive the changing light.
Prerequisites
This guide is written for Omarchy, an opinionated Arch Linux distribution with Hyprland. You’ll need:
- Omarchy installed
sunwaitfor sunrise/sunset calculationshyprsunsetfor screen temperature control (included in Omarchy)
Step 1: Install sunwait
sunwait is a small utility that calculates sunrise and sunset times based on GPS coordinates. It supports different twilight types (civil, nautical, astronomical) and can either report times or wait until an event occurs.
yay -S sunwait
Test it works
# Check if it's currently day or night (using civil twilight)
sunwait poll civil 48.14N 11.58E
# Returns: DAY or NIGHT
# Get today's sunrise and sunset times
sunwait list civil 48.14N 11.58E
# Returns something like: 07:25, 17:19
Step 2: Create the Auto-Theme Script
Create a script at ~/.local/bin/omarchy-auto-theme (or any location in your PATH):
#!/bin/bash
#
# Auto-switches theme and screen temperature based on real sunrise/sunset
# Uses IP geolocation and sunwait for accurate sun position calculation
#
CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/omarchy"
LOCATION_CACHE="$CACHE_DIR/location"
STATE_FILE="$CACHE_DIR/auto-theme-state"
# Theme configuration - customize these!
LIGHT_THEME="Catppuccin Latte"
DARK_THEME="Tokyo Night"
# Screen temperature (night light)
DAY_TEMP=6000
NIGHT_TEMP=4000
mkdir -p "$CACHE_DIR"
# Get location (cached for 24 hours)
get_location() {
local cache_age=86400 # 24 hours
if [[ -f "$LOCATION_CACHE" ]]; then
local file_age=$(($(date +%s) - $(stat -c %Y "$LOCATION_CACHE")))
if [[ $file_age -lt $cache_age ]]; then
cat "$LOCATION_CACHE"
return 0
fi
fi
# Fetch from ipinfo.io
local loc
loc=$(curl -s --connect-timeout 5 ipinfo.io/loc 2>/dev/null)
if [[ -n "$loc" && "$loc" =~ ^-?[0-9]+\.[0-9]+,-?[0-9]+\.[0-9]+$ ]]; then
echo "$loc" > "$LOCATION_CACHE"
echo "$loc"
return 0
fi
# Fallback: use cached even if old
if [[ -f "$LOCATION_CACHE" ]]; then
cat "$LOCATION_CACHE"
return 0
fi
return 1
}
# Convert location to sunwait format
format_location() {
local loc="$1"
local lat lon lat_dir lon_dir
lat=$(echo "$loc" | cut -d',' -f1)
lon=$(echo "$loc" | cut -d',' -f2)
# Determine direction
if (( $(echo "$lat < 0" | bc -l) )); then
lat_dir="S"
lat=$(echo "$lat" | tr -d '-')
else
lat_dir="N"
fi
if (( $(echo "$lon < 0" | bc -l) )); then
lon_dir="W"
lon=$(echo "$lon" | tr -d '-')
else
lon_dir="E"
fi
echo "${lat}${lat_dir} ${lon}${lon_dir}"
}
# Check if it's day or night using sunwait
is_daytime() {
local location="$1"
local result
# sunwait poll returns: DAY (exit 1) or NIGHT (exit 3)
# Using civil twilight (default) for smooth transitions
result=$(sunwait poll civil $location 2>/dev/null)
[[ "$result" == "DAY" ]]
}
# Set screen temperature via hyprsunset
set_temperature() {
local temp="$1"
# Ensure hyprsunset is running
if ! pgrep -x hyprsunset > /dev/null; then
setsid uwsm-app -- hyprsunset &
sleep 1
fi
hyprctl hyprsunset temperature "$temp" 2>/dev/null
}
# Main logic
main() {
local location loc_formatted current_state new_state
location=$(get_location)
if [[ -z "$location" ]]; then
echo "Failed to get location" >&2
exit 1
fi
loc_formatted=$(format_location "$location")
# Determine day/night state
if is_daytime "$loc_formatted"; then
new_state="day"
else
new_state="night"
fi
# Read previous state
current_state=""
[[ -f "$STATE_FILE" ]] && current_state=$(cat "$STATE_FILE")
# Only switch if state changed (or forced)
if [[ "$1" == "--force" ]] || [[ "$current_state" != "$new_state" ]]; then
if [[ "$new_state" == "day" ]]; then
echo "Switching to day mode (light theme)"
# Replace with your theme switching command
omarchy-theme-set "$LIGHT_THEME"
set_temperature "$DAY_TEMP"
else
echo "Switching to night mode (dark theme)"
omarchy-theme-set "$DARK_THEME"
set_temperature "$NIGHT_TEMP"
fi
echo "$new_state" > "$STATE_FILE"
else
echo "Already in $new_state mode, no change needed"
fi
}
# Handle arguments
case "$1" in
--status)
location=$(get_location)
loc_formatted=$(format_location "$location")
echo "Location: $location"
echo "Formatted: $loc_formatted"
echo "Sun state: $(sunwait poll civil $loc_formatted 2>/dev/null)"
echo "Current theme state: $(cat "$STATE_FILE" 2>/dev/null || echo 'unknown')"
;;
--force|"")
main "$1"
;;
--help)
echo "Usage: $(basename $0) [--status|--force|--help]"
echo " --status Show current state and location info"
echo " --force Force theme switch even if state unchanged"
echo " --help Show this help"
;;
*)
echo "Unknown option: $1"
exit 1
;;
esac
Make it executable:
chmod +x ~/.local/bin/omarchy-auto-theme
Step 3: Set Up the Systemd Timer
We’ll use a systemd user timer to run the script every 5 minutes. This approach is lightweight - the script exits immediately if no change is needed.
Create the service file at ~/.config/systemd/user/omarchy-auto-theme.service:
[Unit]
Description=Auto switch theme based on sunrise/sunset
After=graphical-session.target
[Service]
Type=oneshot
ExecStart=%h/.local/bin/omarchy-auto-theme
Environment=DISPLAY=:0
Environment=WAYLAND_DISPLAY=wayland-1
[Install]
WantedBy=default.target
Create the timer file at ~/.config/systemd/user/omarchy-auto-theme.timer:
[Unit]
Description=Check and switch theme every 5 minutes based on sunrise/sunset
[Timer]
OnStartupSec=10sec
OnUnitActiveSec=5min
Persistent=true
[Install]
WantedBy=timers.target
Note: We use OnStartupSec instead of OnBootSec because this is a user timer - it triggers relative to when your user session starts, not system boot.
Enable and start the timer:
systemctl --user daemon-reload
systemctl --user enable --now omarchy-auto-theme.timer
Verify it’s running:
systemctl --user status omarchy-auto-theme.timer
Handling Resume from Suspend
The systemd timer works great, but there’s a catch: if your laptop sleeps overnight and wakes up after sunrise, the timer won’t trigger immediately. User-level timers don’t reliably fire right after resume.
The fix is to hook into hypridle’s after_sleep_cmd. Edit ~/.config/hypr/hypridle.conf and update the after_sleep_cmd line:
general {
# ... other settings ...
after_sleep_cmd = hyprctl dispatch dpms on && omarchy-auto-theme
}
Now the theme check runs immediately when your system wakes from suspend.
Step 4: Test Everything
Check the current status:
omarchy-auto-theme --status
You should see output like:
Location: 48.1374,11.5755
Formatted: 48.1374N 11.5755E
Sun state: NIGHT
Current theme state: night
Force a theme switch to verify it works:
omarchy-auto-theme --force
Understanding Twilight Types
The script uses “civil twilight” by default, but you can adjust this for different behavior:
| Twilight Type | Sun Position | Effect |
|---|---|---|
daylight | At horizon | Exact sunrise/sunset moment |
civil | 6° below | Enough light to work outdoors without artificial light |
nautical | 12° below | Horizon still visible at sea |
astronomical | 18° below | Sky completely dark |
Civil twilight is recommended because it matches when humans naturally perceive the transition between day and night. The switch happens about 30 minutes before actual sunrise and after actual sunset.
To change it, modify the is_daytime() function:
result=$(sunwait poll nautical $location 2>/dev/null)
Customization Options
Fixed Location (No IP Geolocation)
If you prefer not to use IP geolocation, create a permanent location cache:
echo "48.1374,11.5755" > ~/.cache/omarchy/location
chmod 444 ~/.cache/omarchy/location # Make read-only
Different Check Interval
Edit the timer file and change OnUnitActiveSec:
OnUnitActiveSec=10min # Check every 10 minutes instead
Then reload: systemctl --user daemon-reload
Screen Temperature Only (No Theme Switch)
If you only want the night light effect without theme switching, comment out the theme-set lines in the script and keep only the set_temperature calls.
Troubleshooting
Timer not running:
systemctl --user status omarchy-auto-theme.timer
journalctl --user -u omarchy-auto-theme.service -n 20
Wrong location:
curl -s ipinfo.io/loc # Check detected location
# Override if wrong:
echo "YOUR_LAT,YOUR_LON" > ~/.cache/omarchy/location
Screen temperature not changing:
pgrep hyprsunset # Check if running
hyprctl hyprsunset temperature 4000 # Manual test
Why This Approach?
-
True location awareness: The setup auto-detects your location via IP and works when you travel - no manual coordinate updates needed.
-
Civil twilight: The transition happens when it feels like day/night is changing, not at the exact astronomical moment.
-
Unified control: Both the Omarchy theme AND screen temperature change together, creating a cohesive experience.
-
Lightweight: The 5-minute polling approach uses negligible resources - the script exits in milliseconds when no change is needed.
Conclusion
With this setup, your Linux desktop now intelligently adapts to the natural light cycle. No more manually switching themes or dealing with dark mode at 4 PM in winter while it’s still configured for summer schedules.
The system is completely automatic - it detects your location, calculates the real sunrise and sunset times, and smoothly transitions your entire desktop experience. Your eyes will thank you.
Written by Dominic Böttger
← Back to blog