Idle screen
Covers the page after a stretch with no mouse, keyboard or touch, including time spent in another tab, and says when the reader went away.
Install
If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.
npx ermineui initpnpm dlx ermineui inityarn dlx ermineui initbunx ermineui initThen add the component:
npx ermineui add idle-screenpnpm dlx ermineui add idle-screenyarn dlx ermineui add idle-screenbunx ermineui add idle-screenThat copies 3 files into src/components/ui/idle-screen/, 3 helpers into src/lib/ermine/, and the 1 component it is built from. Files you already have are kept.
Use
import { IdleScreen } from '@/components/ui/idle-screen'<IdleScreen @resume="refreshData" /> <!-- five minutes, then waits for "Continue" or Escape -->
<IdleScreen :timeout="60_000" resume-on-activity /> <!-- a screensaver: any movement hides it -->
<IdleScreen v-model:open="away" :disabled="playing">
<template #actions="{ resume }">
<Button @click="resume">Continue</Button>
<Button variant="outline" @click="signOut">Sign out</Button>
</template>
</IdleScreen>In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.
What gets copied
| File | Where it goes |
|---|---|
IdleScreen.vue | src/components/ui/idle-screen/IdleScreen.vue |
index.ts | src/components/ui/idle-screen/index.ts |
use-idle.ts | src/components/ui/idle-screen/use-idle.ts |
layer.ts helper | src/lib/ermine/layer.ts |
primitive.ts helper | src/lib/ermine/primitive.ts |
scroll-lock.ts helper | src/lib/ermine/scroll-lock.ts |
It is built from: Button. These are added with it.
The folders are the suggested ones. ermine.json says where yours are.
Source
This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.
IdleScreen.vue 200 lines
<script setup lang="ts">
import { computed, ref, useId, watch } from 'vue'
import { useLayer } from '../../lib/layer'
import { Portal } from '../../lib/primitive'
import { lockScroll } from '../../lib/scroll-lock'
import { Button } from '../button'
import { useIdle } from './use-idle'
const props = withDefaults(
defineProps<{
/** How long with no activity before the screen shows, in ms. Five minutes unless set. */
timeout?: number
/** Stop watching for idleness, for pages where it does not matter or while something plays. */
disabled?: boolean
/** Hide the screen on any mouse movement, key or touch, like a screensaver. Otherwise it waits for the button or Escape. */
resumeOnActivity?: boolean
title?: string
description?: string
resumeLabel?: string
/** The line that says when the reader left. */
formatSince?: (since: Date) => string
/** Heading level of the title. */
titleAs?: string
}>(),
{
timeout: 5 * 60_000,
title: 'Welcome back',
description: 'Everything is where you left it.',
resumeLabel: 'Continue',
formatSince: (since: Date) => `Away since ${since.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}.`,
titleAs: 'h2',
},
)
/** Whether the screen is showing. Set it to show the screen yourself, or to hide it. */
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{
/** The screen came up. `since` is when the reader was last active. */
idle: [since: Date]
/** The reader came back, after `away` ms. Refresh anything that may have gone stale. */
resume: [away: number]
}>()
defineSlots<{
/** More under the description: what changed while they were away, a sign-out notice. */
default?: (props: { since: Date }) => unknown
/** Buttons of your own in place of the one that resumes. Call `resume` from them. */
actions?: (props: { resume: () => void }) => unknown
}>()
const id = useId()
const content = ref<HTMLElement | null>(null)
const { idle, lastActive, reset } = useIdle(() => props.timeout, { enabled: () => !props.disabled })
const since = ref(new Date())
const now = ref(new Date())
const clock = computed(() => now.value.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }))
watch(idle, (isIdle) => {
if (isIdle) open.value = true
else if (props.resumeOnActivity) open.value = false
})
watch(open, (isOpen, _previous, onCleanup) => {
if (!isOpen) return
// Shown by the timer, the reader left when they were last active; shown by you, they leave now.
since.value = new Date(idle.value ? lastActive.value : Date.now())
emit('idle', since.value)
now.value = new Date()
const tick = setInterval(() => (now.value = new Date()), 1000)
const unlock = lockScroll()
onCleanup(() => {
clearInterval(tick)
unlock()
emit('resume', Date.now() - since.value.getTime())
reset()
})
})
function resume() {
open.value = false
}
useLayer(content, open, {
modal: true,
loopFocus: true,
onDismiss: (reason) => {
if (reason === 'escape') resume()
},
})
</script>
<template>
<Portal>
<Transition name="er-idle-screen">
<div v-if="open" class="er-idle-screen">
<div
ref="content"
role="dialog"
aria-modal="true"
tabindex="-1"
class="er-idle-screen-content"
:aria-labelledby="`${id}-title`"
:aria-describedby="`${id}-description`"
>
<!-- The clock is for a glance from across the room; read aloud it would only repeat itself. -->
<p class="er-idle-screen-clock" aria-hidden="true">{{ clock }}</p>
<component :is="titleAs" :id="`${id}-title`" class="er-idle-screen-title">{{ title }}</component>
<p :id="`${id}-description`" class="er-idle-screen-description">{{ formatSince(since) }} {{ description }}</p>
<slot :since="since" />
<div class="er-idle-screen-actions">
<slot name="actions" :resume="resume">
<Button size="lg" @click="resume">{{ resumeLabel }}</Button>
</slot>
</div>
</div>
</div>
</Transition>
</Portal>
</template>
<style>
@layer components {
.er-idle-screen {
position: fixed;
inset: 0;
z-index: var(--er-z-overlay);
display: grid;
place-items: center;
padding: 1.5rem;
overflow-y: auto;
background: color-mix(in oklab, var(--er-background) 80%, transparent);
color: var(--er-foreground);
-webkit-backdrop-filter: blur(20px) saturate(1.2);
backdrop-filter: blur(20px) saturate(1.2);
}
.er-idle-screen-content {
display: grid;
justify-items: center;
gap: 0.75rem;
max-width: 28rem;
text-align: center;
outline: none;
}
.er-idle-screen-clock {
margin: 0 0 0.5rem;
font-size: clamp(3.5rem, 16vw, 6.5rem);
font-weight: 250;
font-variant-numeric: tabular-nums;
letter-spacing: -0.04em;
line-height: 1;
}
.er-idle-screen-title {
margin: 0;
font-size: 1.5rem;
font-weight: 650;
letter-spacing: -0.01em;
line-height: 1.2;
}
.er-idle-screen-description {
margin: 0;
color: var(--er-muted-foreground);
font-size: 0.9375rem;
line-height: 1.5;
text-wrap: pretty;
}
.er-idle-screen-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
margin-top: 0.75rem;
}
.er-idle-screen-enter-active,
.er-idle-screen-leave-active,
.er-idle-screen-enter-active .er-idle-screen-content {
transition:
opacity calc(var(--er-duration) * 3) var(--er-ease),
transform calc(var(--er-duration) * 3) var(--er-ease);
}
.er-idle-screen-enter-from,
.er-idle-screen-leave-to {
opacity: 0;
}
.er-idle-screen-enter-from .er-idle-screen-content {
transform: translateY(0.5rem);
}
}
</style>index.ts 4 lines
/** Idle screen — covers the page after a stretch with no mouse, keyboard or touch, including time spent in another tab, and says when the reader went away. */
export { default as IdleScreen } from './IdleScreen.vue'
export * from './use-idle'use-idle.ts 66 lines
import { onBeforeUnmount, onMounted, ref, toValue, watch, type MaybeRefOrGetter } from 'vue'
// What counts as the reader being there. Moving the mouse counts, so a reader who is only reading still has to nudge it.
const ACTIVITY = ['pointerdown', 'pointermove', 'keydown', 'wheel', 'touchstart', 'scroll'] as const
/**
* Tracks whether the reader has done nothing for `timeout` ms. Time in another tab or another window
* counts as nothing, and is caught up on as soon as the page is shown again, since browsers slow
* timers down in hidden tabs. `idle` goes back to false on the next activity.
*/
export function useIdle(timeout: MaybeRefOrGetter<number>, options: { enabled?: MaybeRefOrGetter<boolean> } = {}) {
const idle = ref(false)
/** When the reader last did something, as a timestamp in ms. */
const lastActive = ref(Date.now())
let timer: ReturnType<typeof setTimeout> | undefined
const enabled = () => toValue(options.enabled ?? true)
function check() {
clearTimeout(timer)
if (!enabled()) return
const left = lastActive.value + toValue(timeout) - Date.now()
if (left <= 0) idle.value = true
else timer = setTimeout(check, left)
}
/** Start counting again from now, as if the reader had just done something. */
function reset() {
lastActive.value = Date.now()
idle.value = false
check()
}
// Activity only records the time; the one timer re-arms itself, so a moving mouse costs nothing.
function onActivity() {
lastActive.value = Date.now()
if (idle.value) reset()
}
// Coming back to the tab is not activity: the reader should see what they missed.
function onVisibility() {
if (document.visibilityState === 'visible') check()
}
onMounted(() => {
for (const type of ACTIVITY) window.addEventListener(type, onActivity, { passive: true, capture: true })
document.addEventListener('visibilitychange', onVisibility)
reset()
})
onBeforeUnmount(() => {
clearTimeout(timer)
for (const type of ACTIVITY) window.removeEventListener(type, onActivity, { capture: true })
document.removeEventListener('visibilitychange', onVisibility)
})
// Turning it back on, or changing the timeout, starts the count again.
watch([enabled, () => toValue(timeout)], () => {
if (enabled()) return reset()
clearTimeout(timer)
idle.value = false
})
return { idle, lastActive, reset }
}