| name | clicklight-macos-click-highlighter |
| description | Master ClickLight, the macOS menu bar app that highlights clicks for demos, recordings, and UX reviews with real-time visual feedback. |
| triggers | ["how do I use ClickLight for click visualization","configure ClickLight click highlighting","customize ClickLight appearance and settings","build ClickLight from source","troubleshoot ClickLight accessibility permissions","integrate ClickLight into my workflow","modify ClickLight click effects","debug ClickLight overlay issues"] |
ClickLight macOS Click Highlighter Skill
Skill by ara.so — Devtools Skills collection.
Overview
ClickLight is a native macOS menu bar application written in Swift that provides real-time click highlighting for live demos, screen sharing, UX reviews, and presentations. Unlike screen recorders that add click effects in post-production, ClickLight shows visual feedback during the live moment itself.
Key features:
- Real-time click highlights with separate visuals for press, release, right-click, and drag
- Optional laser pointer mode with fading freehand strokes
- Customizable size, duration, intensity, and color
- Native Swift/AppKit implementation
- Menu bar integration with quick presets
Installation
Homebrew (Recommended)
brew tap aurorascharff/clicklight https://github.com/aurorascharff/ClickLight
brew install --cask aurorascharff/clicklight/clicklight
brew upgrade --cask clicklight
brew uninstall --cask clicklight
brew uninstall --cask --zap clicklight
Manual Installation
Download ClickLight.zip from GitHub Releases, extract, and move ClickLight.app to /Applications.
Building from Source
git clone https://github.com/aurorascharff/ClickLight.git
cd ClickLight
swift build -c release
make build
make install
make run
Required Permissions
ClickLight requires Accessibility permission to detect clicks system-wide.
Setup:
- Launch ClickLight (you'll be prompted for permission)
- Go to System Settings → Privacy & Security → Accessibility
- Enable ClickLight in the list
- Quit and relaunch ClickLight from the menu bar
If clicks aren't being detected, verify the permission is enabled and restart the app.
Project Structure
ClickLight/
├── Sources/
│ └── ClickLight/
│ ├── main.swift # Entry point
│ ├── AppDelegate.swift # Menu bar controller
│ ├── ClickMonitor.swift # Global click detection
│ ├── OverlayWindow.swift # Click effect rendering
│ ├── SettingsWindow.swift # Preferences UI
│ └── Preferences.swift # UserDefaults wrapper
├── Package.swift # Swift package manifest
├── Makefile # Build automation
└── docs/ # Documentation
Core Architecture
1. Click Monitoring (ClickMonitor.swift)
Listens for global mouse events using CGEvent tap:
import Cocoa
class ClickMonitor {
private var eventTap: CFMachPort?
weak var delegate: ClickMonitorDelegate?
func start() {
let eventMask = (1 << CGEventType.leftMouseDown.rawValue) |
(1 << CGEventType.leftMouseUp.rawValue) |
(1 << CGEventType.rightMouseDown.rawValue) |
(1 << CGEventType.rightMouseUp.rawValue) |
(1 << CGEventType.leftMouseDragged.rawValue)
guard let eventTap = CGEvent.tapCreate(
tap: .cgSessionEventTap,
place: .headInsertEventTap,
options: .defaultTap,
eventsOfInterest: CGEventMask(eventMask),
callback: { (proxy, type, event, refcon) -> Unmanaged<CGEvent>? in
guard let refcon = refcon else { return Unmanaged.passRetained(event) }
let monitor = Unsafely<ClickMonitor>.fromPointer(refcon)
monitor.handleEvent(type: type, event: event)
return Unmanaged.passRetained(event)
},
userInfo: Unafely.toPointer(self)
) else {
print("Failed to create event tap")
return
}
let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0)
CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes)
CGEvent.tapEnable(tap: eventTap, enable: true)
self.eventTap = eventTap
}
private func handleEvent(type: CGEventType, event: CGEvent) {
let location = event.location
switch type {
case .leftMouseDown:
delegate?.didDetectClick(at: location, type: .press)
case .leftMouseUp:
delegate?.didDetectClick(at: location, type: .release)
case .rightMouseDown:
delegate?.didDetectClick(at: location, type: .rightClick)
case .leftMouseDragged:
delegate?.didDetectDrag(at: location)
default:
break
}
}
func stop() {
if let eventTap = eventTap {
CGEvent.tapEnable(tap: eventTap, enable: false)
CFMachPortInvalidate(eventTap)
}
}
}
protocol ClickMonitorDelegate: AnyObject {
func didDetectClick(at location: CGPoint, type: ClickType)
func didDetectDrag(at location: CGPoint)
}
enum ClickType {
case press, release, rightClick
}
2. Overlay Rendering (OverlayWindow.swift)
Creates a transparent window overlay for click effects:
import Cocoa
class OverlayWindow: NSWindow {
init() {
let screenFrame = NSScreen.screens.reduce(NSRect.zero) { (result, screen) in
return result.union(screen.frame)
}
super.init(
contentRect: screenFrame,
styleMask: .borderless,
backing: .buffered,
defer: false
)
self.isOpaque = false
self.backgroundColor = .clear
self.level = .floating
self.ignoresMouseEvents = true
self.collectionBehavior = [.canJoinAllSpaces, .stationary]
let overlayView = OverlayView(frame: screenFrame)
self.contentView = overlayView
}
}
class OverlayView: NSView {
private var clickEffects: [ClickEffect] = []
override func draw(_ dirtyRect: NSRect) {
guard let context = NSGraphicsContext.current?.cgContext else { return }
for effect in clickEffects {
effect.draw(in: context)
}
}
func addClickEffect(at location: CGPoint, type: ClickType, config: EffectConfig) {
let effect = ClickEffect(
location: location,
type: type,
size: config.size,
duration: config.duration,
intensity: config.intensity,
color: config.color
)
clickEffects.append(effect)
effect.animate { [weak self] in
self?.clickEffects.removeAll { $0 === effect }
self?.needsDisplay = true
}
self.needsDisplay = true
}
}
struct EffectConfig {
let size: CGFloat
let duration: TimeInterval
let intensity: CGFloat
let color: NSColor
}
class ClickEffect {
let location: CGPoint
let type: ClickType
var currentRadius: CGFloat
var currentAlpha: CGFloat = 1.0
private let maxRadius: CGFloat
private let duration: TimeInterval
private let color: NSColor
init(location: CGPoint, type: ClickType, size: CGFloat, duration: TimeInterval, intensity: CGFloat, color: NSColor) {
self.location = location
self.type = type
self.maxRadius = size
self.duration = duration
self.color = color.withAlphaComponent(intensity)
self.currentRadius = size * 0.5
}
func draw(in context: CGContext) {
context.setStrokeColor(color.cgColor)
context.setLineWidth(3.0)
context.setAlpha(currentAlpha)
let rect = CGRect(
x: location.x - currentRadius,
y: location.y - currentRadius,
width: currentRadius * 2,
height: currentRadius * 2
)
context.strokeEllipse(in: rect)
}
func animate(completion: @escaping () -> Void) {
let startTime = Date()
Timer.scheduledTimer(withTimeInterval: 1/60.0, repeats: true) { [weak self] timer in
guard let self = self else {
timer.invalidate()
return
}
let elapsed = Date().timeIntervalSince(startTime)
let progress = min(elapsed / self.duration, 1.0)
if progress >= 1.0 {
timer.invalidate()
completion()
return
}
self.currentRadius = self.maxRadius * (0.5 + progress * 0.5)
self.currentAlpha = 1.0 - progress
}
}
}
3. Preferences Management (Preferences.swift)
import Foundation
import Cocoa
class Preferences {
static let shared = Preferences()
private let defaults = UserDefaults.standard
enum Key: String {
case clickSize = "clickSize"
case clickDuration = "clickDuration"
case clickIntensity = "clickIntensity"
case clickColor = "clickColor"
case laserPointerEnabled = "laserPointerEnabled"
case compactIcon = "compactIcon"
}
var clickSize: CGFloat {
get { CGFloat(defaults.double(forKey: Key.clickSize.rawValue)) }
set { defaults.set(Double(newValue), forKey: Key.clickSize.rawValue) }
}
var clickDuration: TimeInterval {
get { defaults.double(forKey: Key.clickDuration.rawValue) }
set { defaults.set(newValue, forKey: Key.clickDuration.rawValue) }
}
var clickIntensity: CGFloat {
get { CGFloat(defaults.double(forKey: Key.clickIntensity.rawValue)) }
set { defaults.set(Double(newValue), forKey: Key.clickIntensity.rawValue) }
}
var clickColor: NSColor {
get {
guard let data = defaults.data(forKey: Key.clickColor.rawValue),
let color = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSColor.self, from: data) else {
return .systemBlue
}
return color
}
set {
if let data = try? NSKeyedArchiver.archivedData(withRootObject: newValue, requiringSecureCoding: false) {
defaults.set(data, forKey: Key.clickColor.rawValue)
}
}
}
var laserPointerEnabled: Bool {
get { defaults.bool(forKey: Key.laserPointerEnabled.rawValue) }
set { defaults.set(newValue, forKey: Key.laserPointerEnabled.rawValue) }
}
func registerDefaults() {
defaults.register(defaults: [
Key.clickSize.rawValue: 50.0,
Key.clickDuration.rawValue: 0.5,
Key.clickIntensity.rawValue: 0.7,
Key.laserPointerEnabled.rawValue: false,
Key.compactIcon.rawValue: false
])
}
}
4. Menu Bar Integration (AppDelegate.swift)
import Cocoa
@main
class AppDelegate: NSObject, NSApplicationDelegate {
private var statusItem: NSStatusItem!
private var clickMonitor: ClickMonitor!
private var overlayWindow: OverlayWindow!
private var settingsWindow: SettingsWindow?
func applicationDidFinishLaunching(_ notification: Notification) {
Preferences.shared.registerDefaults()
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
statusItem.button?.image = NSImage(systemSymbolName: "circle.circle", accessibilityDescription: "ClickLight")
statusItem.menu = createMenu()
overlayWindow = OverlayWindow()
overlayWindow.orderFront(nil)
clickMonitor = ClickMonitor()
clickMonitor.delegate = self
clickMonitor.start()
}
private func createMenu() -> NSMenu {
let menu = NSMenu()
menu.addItem(NSMenuItem(title: "Settings...", action: #selector(openSettings), keyEquivalent: ","))
menu.addItem(NSMenuItem.separator())
let sizeMenu = NSMenu()
sizeMenu.addItem(createPresetItem(title: "Small", size: 30))
sizeMenu.addItem(createPresetItem(title: "Medium", size: 50))
sizeMenu.addItem(createPresetItem(title: "Large", size: 80))
let sizeItem = NSMenuItem(title: "Size", action: nil, keyEquivalent: "")
sizeItem.submenu = sizeMenu
menu.addItem(sizeItem)
menu.addItem(NSMenuItem.separator())
menu.addItem(NSMenuItem(title: "Test Pulse", action: #selector(testPulse), keyEquivalent: "t"))
menu.addItem(NSMenuItem.separator())
menu.addItem(NSMenuItem(title: "Quit ClickLight", action: #selector(quit), keyEquivalent: "q"))
return menu
}
@objc private func openSettings() {
if settingsWindow == nil {
settingsWindow = SettingsWindow()
}
settingsWindow?.showWindow(nil)
NSApp.activate(ignoringOtherApps: true)
}
@objc private func testPulse() {
guard let screen = NSScreen.main else { return }
let center = CGPoint(x: screen.frame.midX, y: screen.frame.midY)
let config = EffectConfig(
size: Preferences.shared.clickSize,
duration: Preferences.shared.clickDuration,
intensity: Preferences.shared.clickIntensity,
color: Preferences.shared.clickColor
)
(overlayWindow.contentView as? OverlayView)?.addClickEffect(at: center, type: .press, config: config)
}
@objc private func quit() {
NSApplication.shared.terminate(nil)
}
private func createPresetItem(title: String, size: CGFloat) -> NSMenuItem {
let item = NSMenuItem(title: title, action: #selector(applyPreset(_:)), keyEquivalent: "")
item.representedObject = size
return item
}
@objc private func applyPreset(_ sender: NSMenuItem) {
if let size = sender.representedObject as? CGFloat {
Preferences.shared.clickSize = size
}
}
}
extension AppDelegate: ClickMonitorDelegate {
func didDetectClick(at location: CGPoint, type: ClickType) {
let config = EffectConfig(
size: Preferences.shared.clickSize,
duration: Preferences.shared.clickDuration,
intensity: Preferences.shared.clickIntensity,
color: Preferences.shared.clickColor
)
(overlayWindow.contentView as? OverlayView)?.addClickEffect(at: location, type: type, config: config)
}
func didDetectDrag(at location: CGPoint) {
guard Preferences.shared.laserPointerEnabled else { return }
}
}
Configuration
Programmatic Configuration
Modify preferences directly:
Preferences.shared.clickSize = 60.0
Preferences.shared.clickDuration = 0.8
Preferences.shared.clickIntensity = 0.9
Preferences.shared.clickColor = NSColor(red: 1.0, green: 0.3, blue: 0.3, alpha: 1.0)
Preferences.shared.laserPointerEnabled = true
UserDefaults Keys
Access preferences via command line:
defaults read com.yourname.ClickLight
defaults write com.yourname.ClickLight clickSize -float 70
defaults write com.yourname.ClickLight clickDuration -float 0.6
defaults delete com.yourname.ClickLight
Common Patterns
Custom Click Effect Style
class CustomClickEffect: ClickEffect {
override func draw(in context: CGContext) {
let rect = CGRect(
x: location.x - currentRadius,
y: location.y - currentRadius,
width: currentRadius * 2,
height: currentRadius * 2
)
context.setStrokeColor(color.cgColor)
context.setLineWidth(4.0)
context.setAlpha(currentAlpha)
context.stroke(rect)
}
}
Multi-Screen Support
extension OverlayWindow {
func updateForScreenConfiguration() {
let unionFrame = NSScreen.screens.reduce(NSRect.zero) { $0.union($1.frame) }
self.setFrame(unionFrame, display: true)
self.contentView?.frame = unionFrame
}
}
NotificationCenter.default.addObserver(
forName: NSApplication.didChangeScreenParametersNotification,
object: nil,
queue: .main
) { [weak self] _ in
self?.overlayWindow.updateForScreenConfiguration()
}
Keyboard Shortcut Integration
import Carbon
class HotkeyManager {
private var eventHandler: EventHandlerRef?
func registerHotkey(key: UInt32, modifiers: UInt32, action: @escaping () -> Void) {
var hotKeyID = EventHotKeyID(signature: 0x4343, id: 1)
var hotKeyRef: EventHotKeyRef?
RegisterEventHotKey(
key,
modifiers,
hotKeyID,
GetApplicationEventTarget(),
0,
&hotKeyRef
)
}
}
let hotkeyManager = HotkeyManager()
hotkeyManager.registerHotkey(key: 8, modifiers: cmdKey | shiftKey) {
}
Development Workflow
Build Commands
swift build
swift build -c release
swift run
swift package clean
swift test
Makefile Shortcuts
make run
make build
make install
make clean
make archive
Debugging
Enable verbose logging:
#if DEBUG
let logger = Logger(subsystem: "com.yourname.ClickLight", category: "debug")
logger.debug("Click detected at \(location)")
#endif
Monitor click events:
log stream --predicate 'subsystem == "com.yourname.ClickLight"' --level debug
Troubleshooting
Clicks Not Detected
-
Check Accessibility permission:
sqlite3 "/Library/Application Support/com.apple.TCC/TCC.db" \
"SELECT client FROM access WHERE service='kTCCServiceAccessibility'"
-
Verify event tap is active:
if eventTap == nil {
print("ERROR: Event tap failed to create")
print("Check Accessibility permission in System Settings")
}
-
Restart after permission grant:
Must quit and relaunch for permission to take effect.
Overlay Not Visible
-
Check window level:
overlayWindow.level = .floating
-
Verify transparency:
print("Overlay opaque: \(overlayWindow.isOpaque)")
print("Background: \(overlayWindow.backgroundColor)")
-
Test with simple shape:
context.setFillColor(NSColor.red.cgColor)
context.fill(CGRect(x: 100, y: 100, width: 100, height: 100))
Performance Issues
-
Limit active effects:
if clickEffects.count > 10 {
clickEffects.removeFirst(clickEffects.count - 10)
}
-
Optimize drawing:
override func draw(_ dirtyRect: NSRect) {
let visibleEffects = clickEffects.filter { effect in
dirtyRect.intersects(effect.boundingRect)
}
for effect in visibleEffects {
effect.draw(in: context)
}
}
-
Reduce animation frequency:
Timer.scheduledTimer(withTimeInterval: 1/30.0, repeats: true) { ... }
Build Errors
swift package tools-version --set-current
swift package resolve
swift package update
rm -rf .build && swift build
Code Signing Issues
codesign --force --deep --sign - ClickLight.app
codesign --verify --verbose ClickLight.app
codesign -d --entitlements - ClickLight.app
Testing
Manual Testing Checklist
Automated Testing
import XCTest
@testable import ClickLight
final class PreferencesTests: XCTestCase {
func testDefaultValues() {
let prefs = Preferences.shared
XCTAssertEqual(prefs.clickSize, 50.0)
XCTAssertEqual(prefs.clickDuration, 0.5)
XCTAssertEqual(prefs.clickIntensity, 0.7)
}
func testColorPersistence() {
let prefs = Preferences.shared
let testColor = NSColor.red
prefs.clickColor = testColor
let retrieved = prefs.clickColor
XCTAssertEqual(retrieved, testColor)
}
}
Run tests:
swift test
Integration Examples
Use with OBS Studio
ClickLight overlays work with OBS window/display capture:
- Launch ClickLight
- In OBS, add Display Capture source
- ClickLight effects will appear in recording
Use with Screen Studio
ClickLight provides live effects; Screen Studio can add post-production effects:
- ClickLight: Real-time visibility during recording
- Screen Studio: Enhanced post-production effects
Both can work together for maximum visibility.
Use with QuickTime Screen Recording
Advanced Customization
Add Custom Preset
struct Preset {
let name: String
let size: CGFloat
let duration: TimeInterval
let intensity: CGFloat
let color: NSColor
}
let presets = [
Preset(name: "Subtle", size: 30, duration: 0.3, intensity: 0.5, color: .systemGray),
Preset(name: "Bold", size: 80, duration: 0.8, intensity: 1.0, color: .systemRed),
Preset(name: "Presentation", size: 60, duration: 0.6, intensity: 0.8, color: .systemBlue)
]
func applyPreset(_ preset: Preset) {
Preferences.shared.clickSize = preset.size
Preferences.shared.clickDuration = preset.duration
Preferences.shared.clickIntensity = preset.intensity
Preferences.shared.clickColor = preset.color
}
Add Sound Effects
import AVFoundation
class SoundPlayer {
private var audioPlayer: AVAudioPlayer?
func playClickSound() {
guard let soundURL = Bundle.main.url(forResource: "click", withExtension: "wav") else { return }
do {
audioPlayer = try AVAudioPlayer(contentsOf: soundURL)
audioPlayer?.volume = 0.3
audioPlayer?.play()
} catch {
print("Failed to play sound: \(error)")
}
}
}
extension AppDelegate {
func didDetectClick(at location: CGPoint, type: ClickType) {
soundPlayer.playClickSound()
}
}
Export Settings
extension Preferences {
func exportSettings() -> String? {
let settings: [String: Any] = [
"clickSize": clickSize,
"clickDuration": clickDuration,
"clickIntensity": clickIntensity,
"laserPointerEnabled": laserPointerEnabled
]
guard let data = try? JSONSerialization.data(withJSONObject: settings, options: .prettyPrinted),
let json = String(data: data, encoding: .utf8) else {
return nil
}
return json
}
func importSettings(json: String) {
guard let data = json.data(using: .utf8),
let settings = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return
}
if let size = settings["clickSize"] as? Double {
clickSize = CGFloat(size)
}
if let duration = settings["clickDuration"] as? Double {
clickDuration = duration
}
}
}
Resources