@ngrithms/idle
v0.4.0
Published
Modern Angular user-inactivity detector — standalone, signals-first, provideIdle(), SSR-safe, zero dependencies.
Maintainers
Readme
@ngrithms/idle
Signal-first user-inactivity detector for Angular 17+. Standalone, signals primary (observables as bridges), SSR-safe, zero runtime dependencies. Includes multi-tab synchronisation via BroadcastChannel, a sleep watchdog for laptop-lid-closed scenarios, and an optional @ngrithms/idle/keepalive companion that keeps your server's session expiry in lockstep with the browser.
provideIdle({ idleAfter: 5 * 60_000, timeout: 30_000 });Install
npm install @ngrithms/idlePeer-compatible with Angular >=17.2.0 <22.0.0. No runtime dependencies.
Quick start
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideIdle } from '@ngrithms/idle';
export const appConfig: ApplicationConfig = {
providers: [
provideIdle({
idleAfter: 5 * 60_000, // ms of silence before ACTIVE → IDLE
timeout: 30_000, // ms more before IDLE → TIMED_OUT
}),
],
};// any component
import { inject } from '@angular/core';
import { IdleService, IfIdleDirective } from '@ngrithms/idle';
@Component({
imports: [IfIdleDirective],
template: `
<p>Status: {{ idle.state() }} ({{ idle.countdown() }}s)</p>
<div *ngrIfIdle>Looks like you stepped away.</div>
<div *ngrIfIdle="'timedOut'">Session ended. <button (click)="idle.reset()">Resume</button></div>
`,
})
export class Status {
protected readonly idle = inject(IdleService);
}How it works
A three-state machine driven by passive DOM listeners on document:
ACTIVE --(idleAfter ms quiet)--> IDLE --(timeout ms quiet)--> TIMED_OUT
^------------------- activity ----------------------|Activity after TIMED_OUT does not auto-restart — callers must invoke reset() explicitly. This makes TIMED_OUT a deliberate state for auto-logout flows that require fresh authentication on resume.
IDLE is the "soft warning" phase. countdown() ticks 1/sec during this window so you can render an "Are you still there?" prompt with a live countdown.
API
provideIdle(config)
Registers the service tree. Call once in your ApplicationConfig.
IdleService — injected via inject(IdleService)
| Member | Type | Description |
|---|---|---|
| state | Signal<'active' \| 'idle' \| 'timedOut'> | Current state machine value. |
| countdown | Signal<number> | Seconds remaining in the timeout window while 'idle'; 0 otherwise. |
| lastActivity | Signal<number> | Date.now() of the most-recent activity (local or remote). |
| interrupts | Signal<readonly string[]> | Event names currently wired. Empty while stop()-ped. |
| onIdleStart | Observable<void> | Fires once each time the state transitions to 'idle'. |
| onTimeout | Observable<void> | Fires once each time the state transitions to 'timedOut'. |
| onInterrupt | Observable<void> | Fires on every (post-throttle) activity event. |
| reset() | void | Resets to 'active'. Broadcast to other tabs if crossTabReset is on. |
| watch() | void | Manually start watching. No-op if autoStart: true (the default). |
| stop() | void | Detach listeners + close channel. State is preserved. |
*ngrIfIdle
Structural directive that renders its template while the state matches.
<div *ngrIfIdle>Are you still there?</div> <!-- default match: 'idle' -->
<div *ngrIfIdle="'timedOut'">Session ended.</div>
<div *ngrIfIdle="'active'">Welcome back.</div>Configuration reference
| Option | Type | Default | Description |
|---|---|---|---|
| idleAfter | number | 300000 (5 min) | Milliseconds of silence before ACTIVE → IDLE. |
| timeout | number | 30000 (30 s) | Milliseconds in IDLE before IDLE → TIMED_OUT. |
| events | readonly string[] | ['mousemove', 'keydown', 'touchstart', 'scroll', 'click', 'wheel'] | DOM events on document treated as activity. |
| throttleMs | number | 250 | Leading-edge throttle for the activity handler. Prevents change-detection churn on continuous mousemove. |
| autoStart | boolean | true | Start watching on service construction. Set false and call watch() manually for deferred starts. |
| pauseWhenHidden | boolean | true | When document.hidden is true (tab not focused), freeze the timer; resume on visible. Prevents backgrounded tabs from timing out instantly under browser throttling. |
| multiTabSync | boolean | true | Open a BroadcastChannel('ngrithms-idle') so activity in any tab resets the timer in every listening tab. |
| crossTabTimeout | boolean | true | When multiTabSync is on, a TIMED_OUT transition in any tab puts every tab into 'timedOut'. Aligns session-expiry semantics across the app. |
| crossTabReset | boolean | true | When multiTabSync is on, a reset() call in any tab returns every tab to 'active'. Lets a single "Stay signed in" click revive all tabs. |
| crossTabRevival | boolean | false | When on, a remote activity message arriving in a 'timedOut' tab calls reset() on it. Opt-in because most session-expiry flows want 'timedOut' to be terminal. |
| onSystemSleep | 'timeout' \| 'pause' | 'timeout' | What to do when a wall-clock skew is detected (laptop lid closed, tab hard-throttled). 'timeout' snaps to 'timedOut'. 'pause' shifts the anchor timestamp forward by the detected sleep duration so the counter effectively pauses through sleep. |
Recipes
Auto-logout
const idle = inject(IdleService);
const auth = inject(AuthService);
const router = inject(Router);
idle.onTimeout.subscribe(() => {
auth.logout();
router.navigate(['/login'], { queryParams: { reason: 'idle' } });
});"Are you still there?" countdown modal
The library was designed for this pattern. IDLE is the soft-warning phase, TIMED_OUT is the terminal action.
provideIdle({ idleAfter: 14 * 60_000, timeout: 60_000 }); // 14 min then 1 min warning<dialog *ngrIfIdle>
Signing out in {{ idle.countdown() }}s.
<button (click)="idle.reset()">Stay signed in</button>
</dialog>With crossTabReset: true (default), clicking "Stay signed in" in one tab revives all of them. With crossTabTimeout: true (default), expiring in one tab signs out all of them.
Autosave on idle, logout on extended idle (one service, two phases)
Use the existing IDLE / TIMED_OUT transitions instead of two service instances. onIdleStart fires after the short window; onTimeout fires after the long window.
provideIdle({
idleAfter: 10_000, // 10 s → fire autosave
timeout: 10 * 60_000 - 10_000, // 9 min 50 s more → fire logout
});idle.onIdleStart.subscribe(() => this.draft.save());
idle.onTimeout.subscribe(() => this.auth.logout());Pause real-time polling when the user isn't engaged
effect(() => {
if (this.idle.state() === 'active') this.polling.start();
else this.polling.stop();
});Saves significant server load on chat apps and dashboards.
AFK / presence indicator in collaborative apps
Publish your own state over your existing realtime channel.
effect(() => this.presence.publish({ userId: me.id, state: this.idle.state() }));Server keepalive (companion entry point)
See Keepalive below.
Multi-tab and multi-browser
Two tabs in the same browser process: BroadcastChannel covers it. Activity, timeouts, and reset() calls sync automatically with the defaults (multiTabSync: true, crossTabTimeout: true, crossTabReset: true). The user can't extend their session merely by being active in a different tab, and "Stay signed in" works from any tab.
Two tabs in different browsers (Chrome AND Firefox simultaneously) or different machines (laptop AND phone): BroadcastChannel does not cross browser processes. Cookies, localStorage, and sessionStorage are also per-browser. The only way to coordinate idle across browsers or devices is server-driven: the server tracks last-seen across all of a user's sessions and pushes "session expired" / "user active elsewhere" events to each connected client via WebSocket / SSE.
This lib makes it easy to be the client end of that system — subscribe to your transport, call idle.reset() on "user-active-elsewhere" messages and call your own logout flow on onTimeout. But the cross-browser orchestration itself isn't something a client-only library can solve.
System sleep
If a laptop lid is closed (or a tab is hard-throttled by the browser) for long enough, the JS event loop pauses. The library detects this on resume via a watchdog that compares the wall-clock gap between expected and actual tick fires.
onSystemSleep: 'timeout'(default) — snap to'timedOut'. Conservative; assumes the user genuinely walked away.onSystemSleep: 'pause'— shift the relevant anchor timestamp forward by the detected sleep duration. Time spent asleep doesn't count toward the idle counter. Useful for laptops-with-lids scenarios where closing the lid shouldn't terminate a session.
The watchdog also catches sleep that spans the ACTIVE → IDLE boundary, not just sleep during the countdown.
Keepalive
Server-side session timeouts and client-side idle timeouts have to agree, or you get 401s on submit when the server expired while the user was actively typing in a form (no API calls of their own).
Pair provideIdle with provideIdleKeepalive from the secondary entry point:
import { provideIdle } from '@ngrithms/idle';
import { provideIdleKeepalive } from '@ngrithms/idle/keepalive';
providers: [
provideIdle({ idleAfter: 5 * 60_000, timeout: 30_000 }),
provideIdleKeepalive({
url: '/api/keepalive',
intervalSeconds: 60,
method: 'GET', // or 'POST', 'HEAD'
withCredentials: true, // sends cookies
headers: { 'X-CSRF': '...' }, // optional
}),
]- Pings the URL every
intervalSecondswhilestate() === 'active'. - Stops pinging immediately on
'idle'or'timedOut'. - Best-effort: network errors and non-2xx responses are silently swallowed.
- SSR-safe (no pings from the server platform).
Ships as a secondary entry point of the same npm package — not a second package. Avoids the version-mismatch issues that the @ng-idle/core ↔ @ng-idle/keepalive split has caused for years.
SSR
All DOM listeners, BroadcastChannel, document.hidden, and timers are guarded by isPlatformBrowser. On the server platform the service returns a frozen state = 'active' and watch() is a no-op. Safe to import and inject anywhere — no SSR build-fail.
Comparison to @ng-idle/core
| | @ng-idle/core | @ngrithms/idle |
|---|---|---|
| Setup | NgModule forRoot | provideIdle({...}) functional config |
| State surface | RxJS-only | Signals primary, observables as bridges |
| Multi-tab sync | storage events | BroadcastChannel |
| System-sleep handling | none / bug-prone | Watchdog (snap or pause, configurable) |
| Keepalive | separate npm package | Secondary entry point in same package |
| Cross-tab timeout / reset | none | Built-in flags |
| Standalone components | ✗ | ✓ |
| Signals | ✗ | ✓ |
| Bundle (gz, core) | larger | ~4 KB |
License
MIT © Aboud Badra
