Fiber UI LogoFiberUI

useIdle

Detect when the user's system is completely idle (away from keyboard) using the IdleDetector API. Great for auto-lock/logout features.

Installation

npx shadcn@latest add https://r.fiberui.com/r/hooks/use-idle.json

A React hook that implements the Idle Detector API, allowing you to find out when the user is completely idle, such as being away from their device. This goes beyond simple DOM events by detecting system-level inactivity.

Experimental API

The Idle Detector API is currently experimental and requires explicit permission from the user. Check isSupported before requesting permission.

Features

  • System-level Detection - Detects if the user is away from their device entirely
  • Screen State - Can detect if the screen is locked
  • Type Safe - Fully typed for TypeScript
  • Permission Management - Built-in helper for requesting API permissions

Basic Usage

System Idle Detection

System Idle Detection

Detects if the user is away from their keyboard entirely (1 min).

Status: Active
Idle Detection is not supported in this browser. Try Chrome/Edge.
"use client";

import { useIdle } from "@repo/hooks/performance/use-idle";
import { Button } from "@repo/ui/components/button";
import { Coffee, MonitorPlay } from "lucide-react";

export function Example1() {
    const { idle, isSupported, isGranted, requestPermission } = useIdle();

    return (
        <div className="mx-auto flex w-full max-w-sm flex-col items-center justify-center gap-6">
            <div className="text-center">
                <h3 className="text-lg font-medium">System Idle Detection</h3>
                <p className="text-muted-foreground text-sm">
                    Detects if the user is away from their keyboard entirely (1
                    min).
                </p>
            </div>

            <div
                className={`flex items-center justify-center rounded-full border-4 p-8 transition-all duration-700 ${
                    idle
                        ? "border-amber-500 bg-amber-500/10 shadow-[0_0_30px_rgba(245,158,11,0.3)] dark:bg-amber-500/20"
                        : "border-primary/50 bg-primary/10"
                }`}
            >
                {idle ? (
                    <Coffee className="h-16 w-16 animate-pulse text-amber-500" />
                ) : (
                    <MonitorPlay className="text-primary h-16 w-16" />
                )}
            </div>

            <div className="text-xl font-bold tracking-tight">
                Status:{" "}
                {idle ? (
                    <span className="text-amber-500">Away (Idle)</span>
                ) : (
                    <span className="text-primary">Active</span>
                )}
            </div>

            {!isSupported && (
                <div className="rounded-md bg-red-100 p-3 text-center text-sm text-red-600 dark:bg-red-900/30 dark:text-red-400">
                    Idle Detection is not supported in this browser. Try
                    Chrome/Edge.
                </div>
            )}

            {isSupported && !isGranted && (
                <div className="flex w-full flex-col gap-3">
                    <p className="text-muted-foreground text-center text-sm">
                        This feature requires explicit permission.
                    </p>
                    <Button onClick={requestPermission} className="w-full">
                        Enable Idle Detection
                    </Button>
                </div>
            )}
        </div>
    );
}

Auto-Lock Session

Banking Dashboard

Secure Session

Available Balance

$42,450.00

Apple Store-$1,299.00
Salary Deposit+$4,200.00
"use client";

import { useIdle } from "@repo/hooks/performance/use-idle";
import { useState, useEffect } from "react";
import { Button } from "@repo/ui/components/button";
import { Card } from "@repo/ui/components/card";
import { Lock, Unlock } from "lucide-react";

export function Example2() {
    const { idle, isGranted, requestPermission } = useIdle();
    const [isLocked, setIsLocked] = useState(false);

    useEffect(() => {
        // Automatically lock when idle changes to true
        if (idle && isGranted) {
            setIsLocked(true);
        }
    }, [idle, isGranted]);

    return (
        <Card className="relative mx-auto w-full max-w-sm overflow-hidden p-6">
            <div
                className={`transition-all duration-500 ${isLocked ? "pointer-events-none select-none opacity-50 blur-md" : ""}`}
            >
                <div className="mb-6 flex items-center justify-between">
                    <h3 className="font-bold">Banking Dashboard</h3>
                    <div className="rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-700 dark:bg-green-900 dark:text-green-400">
                        Secure Session
                    </div>
                </div>

                <div className="space-y-4">
                    <div>
                        <p className="text-muted-foreground text-xs uppercase tracking-wider">
                            Available Balance
                        </p>
                        <p className="text-3xl font-black">$42,450.00</p>
                    </div>

                    <div className="mt-6 space-y-2">
                        <div className="flex items-center justify-between border-b py-2">
                            <span className="text-sm">Apple Store</span>
                            <span className="text-destructive text-sm font-medium">
                                -$1,299.00
                            </span>
                        </div>
                        <div className="flex items-center justify-between border-b py-2">
                            <span className="text-sm">Salary Deposit</span>
                            <span className="text-sm font-medium text-green-600 dark:text-green-400">
                                +$4,200.00
                            </span>
                        </div>
                    </div>
                </div>

                {!isGranted && (
                    <Button
                        variant="outline"
                        size="sm"
                        onClick={requestPermission}
                        className="mt-6 w-full"
                    >
                        Enable Auto-Lock Feature
                    </Button>
                )}
            </div>

            {/* Lock Overlay */}
            {isLocked && (
                <div className="bg-background/50 animate-in fade-in zoom-in-95 absolute inset-0 z-10 flex flex-col items-center justify-center p-6 text-center backdrop-blur-sm">
                    <div className="bg-primary/20 text-primary mb-4 flex h-16 w-16 items-center justify-center rounded-full">
                        <Lock className="h-8 w-8" />
                    </div>
                    <h4 className="mb-2 text-xl font-bold">Session Locked</h4>
                    <p className="text-muted-foreground mb-6 text-sm">
                        Your session was automatically locked because you were
                        away from your device.
                    </p>

                    <Button
                        onClick={() => setIsLocked(false)}
                        className="w-full gap-2"
                    >
                        <Unlock className="h-4 w-4" />
                        Unlock Session
                    </Button>
                </div>
            )}
        </Card>
    );
}

Privacy Protection

Privacy Protection

Hides sensitive content when you walk away.

Confidential Q3 Strategy

Content Hidden

Document protected while you are away from the system.

"use client";

import { useIdle } from "@repo/hooks/performance/use-idle";
import { useState, useEffect } from "react";
import { Button } from "@repo/ui/components/button";
import { FileText, EyeOff } from "lucide-react";

export function Example3() {
    const { idle, isGranted, requestPermission } = useIdle();
    const [blurEnabled, setBlurEnabled] = useState(true);

    const isCurrentlyHidden = idle && blurEnabled && isGranted;

    return (
        <div className="mx-auto flex w-full max-w-lg flex-col gap-6">
            <div className="bg-muted/50 flex flex-col items-center justify-between gap-4 rounded-lg p-4 sm:flex-row">
                <div className="space-y-1 text-center sm:text-left">
                    <h3 className="text-sm font-bold">Privacy Protection</h3>
                    <p className="text-muted-foreground text-xs">
                        Hides sensitive content when you walk away.
                    </p>
                </div>
                <div className="flex gap-2">
                    {!isGranted && (
                        <Button
                            size="sm"
                            variant="outline"
                            onClick={requestPermission}
                        >
                            Allow Sensor
                        </Button>
                    )}
                    <Button
                        size="sm"
                        variant={blurEnabled ? "default" : "secondary"}
                        onClick={() => setBlurEnabled(!blurEnabled)}
                        isDisabled={!isGranted}
                    >
                        {blurEnabled ? "Protection ON" : "Protection OFF"}
                    </Button>
                </div>
            </div>

            <div className="bg-background relative overflow-hidden rounded-xl border">
                {/* Simulated Document Content */}
                <div
                    className={`p-8 transition-all duration-1000 ${isCurrentlyHidden ? "scale-95 opacity-20 blur-xl grayscale" : ""}`}
                >
                    <div className="text-primary mb-6 flex items-center gap-2">
                        <FileText className="h-5 w-5" />
                        <h2 className="font-bold">Confidential Q3 Strategy</h2>
                    </div>

                    <div className="space-y-4">
                        <div className="bg-muted h-4 w-3/4 rounded"></div>
                        <div className="bg-muted h-4 w-full rounded"></div>
                        <div className="bg-muted h-4 w-5/6 rounded"></div>
                        <div className="bg-primary/5 border-primary/20 !mb-6 mt-6 h-32 w-full rounded border"></div>
                        <div className="bg-muted h-4 w-2/3 rounded"></div>
                        <div className="bg-muted h-4 w-full rounded"></div>
                    </div>
                </div>

                {/* Privacy Overlay */}
                <div
                    className={`pointer-events-none absolute inset-0 flex flex-col items-center justify-center p-6 text-center transition-all duration-500 ${
                        isCurrentlyHidden
                            ? "bg-background/40 opacity-100"
                            : "opacity-0"
                    }`}
                >
                    <EyeOff className="text-muted-foreground mb-4 h-12 w-12" />
                    <h4 className="text-foreground text-lg font-bold">
                        Content Hidden
                    </h4>
                    <p className="text-foreground/80 mt-1 max-w-xs text-sm">
                        Document protected while you are away from the system.
                    </p>
                </div>
            </div>
        </div>
    );
}

Smart Time Tracker

Smart Time Tracker

Automatically pauses billing/tracking when you walk away.

00:00
"use client";

import { useIdle } from "@repo/hooks/performance/use-idle";
import { useState, useEffect } from "react";
import { Button } from "@repo/ui/components/button";
import { Card } from "@repo/ui/components/card";
import { Play, Pause, Square } from "lucide-react";

export function Example4() {
    const { idle, isGranted, requestPermission } = useIdle();
    const [seconds, setSeconds] = useState(0);
    const [isRunning, setIsRunning] = useState(false);
    const [wasPausedByIdle, setWasPausedByIdle] = useState(false);

    // Timer logic
    useEffect(() => {
        let interval: NodeJS.Timeout;
        if (isRunning) {
            interval = setInterval(() => {
                setSeconds((s) => s + 1);
            }, 1000);
        }
        return () => clearInterval(interval);
    }, [isRunning]);

    // Idle Pause Logic
    useEffect(() => {
        if (!isGranted) return;

        if (idle && isRunning) {
            // User went idle while timer was running -> Auto pause
            setIsRunning(false);
            setWasPausedByIdle(true);
        } else if (!idle && !isRunning && wasPausedByIdle) {
            // User came back and it was paused by the idle detector -> Auto resume
            setIsRunning(true);
            setWasPausedByIdle(false);
        }
    }, [idle, isGranted, isRunning, wasPausedByIdle]);

    const formatTime = (secs: number) => {
        const m = Math.floor(secs / 60)
            .toString()
            .padStart(2, "0");
        const s = (secs % 60).toString().padStart(2, "0");
        return `${m}:${s}`;
    };

    return (
        <Card className="mx-auto flex max-w-sm flex-col items-center justify-center gap-6 p-6">
            <div className="relative w-full text-center">
                <h3 className="text-lg font-medium">Smart Time Tracker</h3>
                <p className="text-muted-foreground mt-1 text-sm">
                    Automatically pauses billing/tracking when you walk away.
                </p>

                {!isGranted && (
                    <Button
                        variant="link"
                        onClick={requestPermission}
                        className="mt-2 h-auto p-0 text-xs"
                    >
                        Enable Auto-Pause
                    </Button>
                )}
            </div>

            <div className="relative">
                <div
                    className={`text-6xl font-black tabular-nums tracking-tighter transition-colors ${
                        idle
                            ? "opacity-50"
                            : isRunning
                              ? "text-primary"
                              : "text-muted-foreground"
                    }`}
                >
                    {formatTime(seconds)}
                </div>

                {idle && wasPausedByIdle && (
                    <div className="absolute -bottom-6 left-1/2 -translate-x-1/2 animate-pulse whitespace-nowrap rounded-full bg-amber-500/10 px-2 py-0.5 text-xs font-bold text-amber-500">
                        Auto-Paused (Away)
                    </div>
                )}
            </div>

            <div className="mt-4 flex w-full gap-2">
                <Button
                    className="flex-1 gap-2"
                    variant={isRunning ? "outline" : "default"}
                    onClick={() => {
                        setIsRunning(!isRunning);
                        setWasPausedByIdle(false); // Reset auto-pause tracking on manual action
                    }}
                >
                    {isRunning ? (
                        <>
                            <Pause className="h-4 w-4" /> Pause
                        </>
                    ) : (
                        <>
                            <Play className="h-4 w-4" /> Start
                        </>
                    )}
                </Button>

                <Button
                    variant="destructive"
                    size="icon"
                    onClick={() => {
                        setIsRunning(false);
                        setSeconds(0);
                        setWasPausedByIdle(false);
                    }}
                >
                    <Square className="h-4 w-4" />
                </Button>
            </div>
        </Card>
    );
}

API Reference

Hook Signature

function useIdle(): UseIdleReturn;

Return Value

PropertyTypeDescription
idlebooleantrue if the device is idle or the screen is locked
isSupportedbooleantrue if the Idle Detector API is available
isGrantedbooleantrue if permission has been granted by the user
requestPermission() => Promise<boolean>Helper to prompt the user for permission to use it

Hook Source Code

import { useState, useEffect, useCallback, useRef } from "react";

interface IdleState {
    idle: boolean;
    isSupported: boolean;
    isGranted: boolean;
}

/**
 * Detect when the user's system is completely idle using the IdleDetector API.
 */
export function useIdle() {
    const [state, setState] = useState<IdleState>({
        idle: false,
        isSupported: false,
        isGranted: false,
    });

    const isSupported =
        typeof window !== "undefined" && "IdleDetector" in window;

    // We can only use the IdleDetector interface if supported.
    // The IdleDetector needs permission before using it.
    const requestPermission = useCallback(async () => {
        if (!isSupported) return false;
        try {
            // @ts-ignore - IdleDetector is an experimental API
            const status = await IdleDetector.requestPermission();
            setState((prev) => ({ ...prev, isGranted: status === "granted" }));
            return status === "granted";
        } catch (error) {
            console.error("Failed to request IdleDetector permission:", error);
            return false;
        }
    }, [isSupported]);

    useEffect(() => {
        setState((prev) => ({ ...prev, isSupported }));

        if (!isSupported) return;

        // Check if permission was already granted
        // @ts-ignore
        navigator.permissions
            .query({ name: "idle-detection" as PermissionName })
            .then((status) => {
                if (status.state === "granted") {
                    setState((prev) => ({ ...prev, isGranted: true }));
                }
            });
    }, [isSupported]);

    useEffect(() => {
        if (!state.isGranted) return;

        let detector: any;
        const abortController = new AbortController();

        const startIdleDetection = async () => {
            try {
                // @ts-ignore
                detector = new IdleDetector();
                detector.addEventListener("change", () => {
                    const isIdle =
                        detector.userState === "idle" ||
                        detector.screenState === "locked";
                    setState((prev) => ({ ...prev, idle: isIdle }));
                });

                await detector.start({
                    threshold: 60000,
                    signal: abortController.signal,
                });
            } catch (err) {
                console.error("Failed to start IdleDetector:", err);
            }
        };

        startIdleDetection();

        return () => {
            abortController.abort();
        };
    }, [state.isGranted]);

    return { ...state, requestPermission };
}