Session Timeout
This is the thing every large organisation builds itself, and gets wrong. Either no warning comes, and the user loses a half-completed form. Or the dialog steals focus in the middle of a sentence. Or the screen reader reads the countdown every single second.
You write the dialog and the text yourself, in the language the app uses. The component tracks activity, opens the dialog, fills in the number and announces the countdown.
// main.ts, runs once when the app startsimport { defineLtSessionTimeout } from "@lo-tek/interfaces/session-timeout"import "@lo-tek/interfaces/tokens.css"import "@lo-tek/interfaces/session-timeout.css"
defineLtSessionTimeout()How to use it
Section titled “How to use it”Here the session is set to five seconds, so you do not have to wait 25 minutes. Stop touching the keyboard and mouse, and the warning appears.
<!-- The stylesheets the page needs. Without a build step it is `<link>`, not `import`: a browser does not resolve a package name. --><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/src/tokens/tokens.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/src/components/frame/session-timeout/session-timeout.css"><!-- Or everything in one file: https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/dist/lotek.css --><lt-session-timeout class="lt-session-timeout" warn-at="1500" expires-at="1800" data-ignore-morph> <dialog class="lt-session-timeout__dialog" role="alertdialog" aria-labelledby="session-title"> <h2 class="lt-session-timeout__title" id="session-title">Your session is ending</h2> <p class="lt-session-timeout__text"> Your session on the Lattice ends in <span class="lt-session-timeout__count" aria-hidden="true"></span>. Unsaved routes will be lost. </p> <span class="lt-sr-only" role="status"></span> <form method="dialog" class="lt-session-timeout__actions"> <button class="lt-button" value="extend">Stay connected</button> <button class="lt-button" data-variant="secondary" value="logout" formmethod="post" formaction="/log-out">Log out</button> </form> </dialog></lt-session-timeout>
<!-- Once for the whole app, in the page template. Not once per component. --><script type="module"> import { defineLtSessionTimeout } from "https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/dist/components/frame/session-timeout/lt-session-timeout.js"
defineLtSessionTimeout()</script>
<script type="module"> const session = document.querySelector("lt-session-timeout")
session.addEventListener("session-extend", async () => { await fetch("/api/extend-session", { method: "POST" }) })
session.addEventListener("session-expired", () => { location.href = "/log-in?reason=expired" })</script>// src/lotek.d.ts, gives <lt-session-timeout> types in JSXimport "@lo-tek/interfaces/react-jsx"import "@lo-tek/interfaces/session-timeout.css"import { useEffect, useId, useRef } from "react"import { lt } from "@lo-tek/interfaces/react"import { defineLtSessionTimeout } from "@lo-tek/interfaces/session-timeout"import "@lo-tek/interfaces/tokens.css"
defineLtSessionTimeout()
export function SessionWarning() { const ref = useRef<HTMLElement>(null) const session = lt.sessionTimeout({ titleId: useId(), warnAt: 1500, expiresAt: 1800 })
useEffect(() => { const element = ref.current if (!element) return
const extend = () => fetch("/api/extend-session", { method: "POST" }) const expired = () => { location.href = "/log-in?reason=expired" }
element.addEventListener("session-extend", extend) element.addEventListener("session-expired", expired) return () => { element.removeEventListener("session-extend", extend) element.removeEventListener("session-expired", expired) } }, [])
return ( <lt-session-timeout ref={ref} {...session.host}> <dialog {...session.dialog}> <h2 {...session.title}>Your session is ending</h2> <p {...session.text}> Your session on the Lattice ends in <span {...session.count} />. Unsaved routes will be lost. </p> <span {...session.live} /> <form {...session.actions}> <button {...session.extend}>Stay connected</button> {/* Posts to the logout on the server, which ends the session there. */} <button {...session.logout} formMethod="post" formAction="/log-out">Log out</button> </form> </dialog> </lt-session-timeout> )}The text is ordinary JSX, so it can come from t() in i18next or from any other setup. See Translation.
<!-- Datastar itself. Without it the `data-` attributes below do nothing. --><script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar.js"></script>
<!-- The stylesheets the page needs. Without a build step it is `<link>`, not `import`: a browser does not resolve a package name. --><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/src/tokens/tokens.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/src/components/frame/session-timeout/session-timeout.css"><!-- Or everything in one file: https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/dist/lotek.css -->
<!-- Once for the whole app, in the page template. Not once per component. --><script type="module"> import { defineLtSessionTimeout } from "https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/dist/components/frame/session-timeout/lt-session-timeout.js"
defineLtSessionTimeout()</script>
<lt-session-timeout class="lt-session-timeout" warn-at="1500" expires-at="1800" data-ignore-morph data-on:session-extend="@post('/api/extend-session')" data-on:session-expired="window.location = '/log-in?reason=expired'"> <dialog class="lt-session-timeout__dialog" role="alertdialog" aria-labelledby="session-title"> <h2 class="lt-session-timeout__title" id="session-title">Your session is ending</h2> <p class="lt-session-timeout__text"> Your session on the Lattice ends in <span class="lt-session-timeout__count" aria-hidden="true"></span>. Unsaved routes will be lost. </p> <span class="lt-sr-only" role="status"></span> <form method="dialog" class="lt-session-timeout__actions"> <button class="lt-button" value="extend">Stay connected</button> <button class="lt-button" data-variant="secondary" value="logout" formmethod="post" formaction="/log-out">Log out</button> </form> </dialog></lt-session-timeout>data-ignore-morph tells the morph to leave the warning alone. Without it the morph removes open and the number while the dialog is in the top layer. The component puts open back, and the number returns on the next tick, but with the attribute the number is there all the time.
On a Kotlin server you call Lt.sessionTimeout(), here with kotlinx.html and lo-tek-kotlinx-html. The server writes the dialog and every text in it, in its own language.
import kotlinx.html.*import no.lotek.*import no.lotek.html.ltimport no.lotek.html.ltSessionTimeout
fun FlowContent.sessionWarning() { val session = Lt.sessionTimeout(titleId = "session-title", warnAt = 1500, expiresAt = 1800) ltSessionTimeout { lt(session.host) dialog { lt(session.dialog) h2 { lt(session.title); +"Your session is ending" } p { lt(session.text) +"Your session on the Lattice ends in " span { lt(session.count) } +". Unsaved routes will be lost." } span { lt(session.live) } form { lt(session.actions) attributes["method"] = "dialog" button { lt(Lt.button(), session.extend); +"Stay connected" } button { lt(Lt.button(variant = ButtonVariant.SECONDARY), session.logout) // Logging out is a post to the server, which ends the session. formMethod = ButtonFormMethod.post formAction = "/log-out" +"Log out" } } } } script(type = "module") { unsafe { +""" const session = document.querySelector("lt-session-timeout")
session.addEventListener("session-extend", async () => { await fetch("/api/extend-session", { method: "POST" }) })
session.addEventListener("session-expired", () => { location.href = "/log-in?reason=expired" }) """ } }}// Once for the whole app, in the page template. Not once per component.head { link(rel = "stylesheet", href = LotekWebJar.CSS) }body { sessionWarning() script(type = "module") { unsafe { +"import { defineLt } from \"${LotekWebJar.REGISTER}\"; defineLt()" } }}The heading’s id comes from the template, and the dialog takes its name from it. Make it from a fixed value, never from a counter, so it is the same on every render: a morph recognises an element by its id. See Ids that survive an update.
Why you write the dialog yourself
Section titled “Why you write the dialog yourself”The text in the warning belongs to the app. It should be in the language the app uses, with the words the app uses about logging in, and come from the translation setup the app already has. If the text were in the component, it could not be translated at all. See Translation.
The component finds the parts by what has to be there anyway:
| Part | Recognised by |
|---|---|
| The dialog | The first <dialog> inside the element |
| The number | .lt-session-timeout__count. Is empty, and the component fills it in |
| The announcement | [role=status]. Is empty, and the component fills it in |
| The buttons | Buttons in a <form method="dialog"> |
The buttons are ordinary buttons. The browser closes the dialog and sets returnValue to the button’s value, without script. value="logout" logs out. Everything else, Escape and the button with value="extend" included, means the user wants to continue. In the recipes the logout button also has formmethod="post" and formaction: it submits the form to the logout on the server, which ends the session there, and the page leaves before the dialog closes. Until the server answers, the countdown runs on: if the time runs out first, session-expired takes the user to the log-in page instead.
If the class lt-session-timeout__dialog, role="alertdialog" or a name on the dialog is missing, the component fills them in. The name comes from the first heading, which gets an id if it has none. What the renderer wrote stays.
The countdown is the client’s clock, and changes depending on when the user last touched the keyboard. That is why the builder function sets data-ignore-morph, so a patch from the server leaves the warning alone. If a patch still removes open from a dialog that is in the top layer, the component puts it back. See Markup and behaviour.
Attributes
Section titled “Attributes”| Attribute | Meaning | Default |
|---|---|---|
warn-at | Seconds without activity before the warning appears | 1500 (25 minutes) |
expires-at | Seconds without activity before the session is over | 1800 (30 minutes) |
activity-interval | Seconds between each session-activity while the user is active. At least 1 | 60 |
Activity is keystrokes, clicks and scrolling, also scrolling in a table or a panel. Scrolling also counts when a screen reader in browse mode scrolls the page, since it does not send keystrokes to the page. This means that scrolling done by code, like a log that follows along, also keeps the clock going. While the dialog is open, activity does not count, otherwise a random mouse click would extend the session without the user meaning to.
If warn-at or expires-at is not a number greater than or equal to zero, the default is used, and if warn-at is not less than expires-at, the warning never appears. The component warns about both in the console.
After session-expired and session-logout the component stands still until extend() or reset() is called. Without that, an app that did not navigate away got a new dialog and a new session-expired every expires-at seconds, for a session that was already gone.
Events
Section titled “Events”| Event | When | What you do |
|---|---|---|
session-warn | The dialog opens | Usually nothing |
session-extend | The user chooses to continue, with the button or Escape | Ask the server to extend the session |
session-logout | The user chooses to log out with a button that closes the dialog. A button with formmethod="post", as in the recipes, logs out by submitting, and the event does not come | Send the user to the logout |
session-expired | The time is up | Send the user to the login |
session-activity | The user is active, at most once per activity-interval | Ask the server to keep the session alive |
The component does not know how your session is extended. It tells you, you make the call.
Listen for session-activity if the server session is only extended by calls to the server. The clock here is reset by activity in the browser. A user typing in a long form without a single call therefore never gets the warning, while the server session expires, and the submission fails. The event comes at the start of an interval and at the end if the user has done something since, so the server is never more than one interval behind.
Several tabs do not share a clock. Each tab counts its own activity, so a tab the user is not using shows the warning and sends session-expired while the user is working in another. If the tabs should follow each other, let them tell each other and call reset(), both when the user is active and when the user chooses to continue in the warning. A tab that is already logged out should not be started again:
const channel = new BroadcastChannel("session")let loggedOut = falsefor (const name of ["session-activity", "session-extend"]) { session.addEventListener(name, () => channel.postMessage("active"))}for (const name of ["session-expired", "session-logout"]) { session.addEventListener(name, () => (loggedOut = true))}channel.addEventListener("message", () => { if (!loggedOut) session.reset()})Methods
Section titled “Methods”| Method | What it does |
|---|---|
extend() | Resets the clock, closes the warning and sends session-extend |
reset() | Resets the clock and closes the warning without sending anything. For an app that has already extended the session itself, as when an autosave went through |
extendSession(element) and resetSession(element) from @lo-tek/interfaces/session-timeout do the same, but first wait for the element to be registered.
Classes you can style
Section titled “Classes you can style”| Class | What it is |
|---|---|
.lt-session-timeout | The host element. Is display: contents and takes no space |
.lt-session-timeout__dialog | The dialog itself |
.lt-session-timeout__title | The heading |
.lt-session-timeout__text | The paragraph with the countdown in it |
.lt-session-timeout__count | The number that counts down |
.lt-session-timeout__actions | The form with the buttons |
Shape and size are read from component variables, with the system’s value as the fallback:
| Variable | Fallback |
|---|---|
--lt-session-timeout-width | 26rem |
--lt-session-timeout-padding | var(--lt-spacing-5) |
--lt-session-timeout-radius | var(--lt-spacing-1) |
.lt-session-timeout__dialog { --lt-session-timeout-width: 32rem;}Accessibility
Section titled “Accessibility”
What a screen reader is given when the recipe above is used, step by step. It is checked through AT-SPI, the
interface Orca reads, in Chromium and Firefox, on every change, by check-accessibility-tree.ts, and
every change the check lists as a mutation must make it fail.
- Wait 25 minutes: focus on the
button"Stay connected"; inside thealertdialog"Your session is ending". - Wait 3 minutes: focus on the
button"Stay connected"; "Your session on the Lattice ends in 2 minutes. Unsaved routes will be lost." announced once, polite.
Where a browser does not do what the specification says, which the check requires until the browser changes:
- Firefox: Firefox 150, 2026-10-29: role=alertdialog on <dialog> is ROLE_DIALOG, where Core-AAM maps alertdialog to ROLE_ALERT.
The dialog is a real <dialog> opened with showModal(), so the focus trap, Escape and the top layer come from the browser. It has role="alertdialog", because it interrupts something the user did not ask to be interrupted in.
Three things are done on purpose, and all three are reasons this is hard to get right yourself:
- Focus returns to where the user was. Without that, the next keystroke starts at the top of the page, in the middle of a form.
- The countdown is not announced every second. The number on the screen has
aria-hidden, and the announcement speaks at two minutes, one minute, 30 seconds and 10 seconds. An announcement every second makes the dialog unusable. - The announcement is in the page’s language. It is your paragraph, with the number replaced by a spoken duration. The duration is written with the browser’s
Intlfrom thelangof the page, so it says “1 minute and 30 seconds” on an English page and “1 minutt og 30 sekunder” on a Norwegian one.
Put the button that extends first. It is the one most people want, and it should be the first thing the keyboard reaches.