Offline state
Subscribes to real online and offline events rather than pretending.
Statesintermediateerrorofflineconnectivitypwa
Live preview
full widthLive preview — open it in a new tab for the full-height version.
Source
This exact file renders the preview above.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
'use client'
import { useEffect, useState } from 'react'
import { CloudOff, RefreshCw, Wifi } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
import { EmptyState } from '@/components/ui/empty-state'
import { Status } from '@/components/ui/status'
/**
* Offline state
*
* Subscribes to the browser's real online/offline events, so the section
* reflects actual connectivity rather than pretending. Everything that works
* offline is listed explicitly — the most reassuring thing an offline screen
* can do is be specific about what is still available.
*/
const available = [
'Pages you have already visited',
'Your theme, palette and density preferences',
'Anything drafted locally in a form',
]
export default function OfflineSection() {
const [online, setOnline] = useState(true)
useEffect(() => {
const update = () => setOnline(navigator.onLine)
update()
window.addEventListener('online', update)
window.addEventListener('offline', update)
return () => {
window.removeEventListener('online', update)
window.removeEventListener('offline', update)
}
}, [])
return (
<section className="bg-canvas py-section">
<Container size="narrow">
<div className="mb-6 flex justify-center">
<Status
appearance="pill"
kind={online ? 'operational' : 'down'}
label={online ? 'Connection restored' : 'No connection'}
/>
</div>
<EmptyState
icon={online ? <Wifi className="size-5" /> : <CloudOff className="size-5" />}
title={online ? 'You are back online' : 'You are offline'}
description={
online
? 'The connection has returned. Reload to fetch the latest data.'
: 'Foundry cannot reach the network. Anything you have already loaded is still available.'
}
action={
<Button
onClick={() => window.location.reload()}
leadingIcon={<RefreshCw className="size-4" />}
>
Reload
</Button>
}
/>
<div className="mx-auto mt-8 max-w-md rounded-lg border border-line bg-surface p-5">
<p className="label-caps text-ink-subtle">Still available offline</p>
<ul className="mt-3 flex flex-col gap-2">
{available.map((item) => (
<li key={item} className="text-sm text-ink-muted">
· {item}
</li>
))}
</ul>
</div>
</Container>
</section>
)
}
components/blocks/sections/error/offline.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
'use client'
import { useEffect, useState } from 'react'
import { CloudOff, RefreshCw, Wifi } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
import { EmptyState } from '@/components/ui/empty-state'
import { Status } from '@/components/ui/status'
/**
* Offline state
*
* Subscribes to the browser's real online/offline events, so the section
* reflects actual connectivity rather than pretending. Everything that works
* offline is listed explicitly — the most reassuring thing an offline screen
* can do is be specific about what is still available.
*/
const available = [
'Pages you have already visited',
'Your theme, palette and density preferences',
'Anything drafted locally in a form',
]
export default function OfflineSection() {
const [online, setOnline] = useState(true)
useEffect(() => {
const update = () => setOnline(navigator.onLine)
update()
window.addEventListener('online', update)
window.addEventListener('offline', update)
return () => {
window.removeEventListener('online', update)
window.removeEventListener('offline', update)
}
}, [])
return (
<section className="bg-canvas py-section">
<Container size="narrow">
<div className="mb-6 flex justify-center">
<Status
appearance="pill"
kind={online ? 'operational' : 'down'}
label={online ? 'Connection restored' : 'No connection'}
/>
</div>
<EmptyState
icon={online ? <Wifi className="size-5" /> : <CloudOff className="size-5" />}
title={online ? 'You are back online' : 'You are offline'}
description={
online
? 'The connection has returned. Reload to fetch the latest data.'
: 'Foundry cannot reach the network. Anything you have already loaded is still available.'
}
action={
<Button
onClick={() => window.location.reload()}
leadingIcon={<RefreshCw className="size-4" />}
>
Reload
</Button>
}
/>
<div className="mx-auto mt-8 max-w-md rounded-lg border border-line bg-surface p-5">
<p className="label-caps text-ink-subtle">Still available offline</p>
<ul className="mt-3 flex flex-col gap-2">
{available.map((item) => (
<li key={item} className="text-sm text-ink-muted">
· {item}
</li>
))}
</ul>
</div>
</Container>
</section>
)
}
components/ui/status.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
import { CheckCircle2, AlertTriangle, XCircle, Info, CircleDashed, Clock } from 'lucide-react'
import { cn } from '@/lib/cn'
/**
* Status
*
* Deliberately separate from Badge. A status describes the *state of a thing*
* (a deployment, an invoice, a service) and therefore always pairs an icon
* with a label — colour is the third signal, never the only one. That rule is
* what makes the library legible to colour-blind users without a theme switch.
*/
export type StatusKind = 'operational' | 'degraded' | 'down' | 'pending' | 'info' | 'idle'
export interface StatusProps {
kind: StatusKind
label: string
/** `dot` for dense tables, `pill` for standalone display. */
appearance?: 'dot' | 'pill' | 'inline'
className?: string
}
const config = {
operational: {
icon: CheckCircle2,
text: 'text-success',
bg: 'bg-success-soft border-success-line',
dot: 'bg-success',
},
degraded: {
icon: AlertTriangle,
text: 'text-warning',
bg: 'bg-warning-soft border-warning-line',
dot: 'bg-warning',
},
down: {
icon: XCircle,
text: 'text-danger',
bg: 'bg-danger-soft border-danger-line',
dot: 'bg-danger',
},
pending: { icon: Clock, text: 'text-info', bg: 'bg-info-soft border-info-line', dot: 'bg-info' },
info: { icon: Info, text: 'text-info', bg: 'bg-info-soft border-info-line', dot: 'bg-info' },
idle: {
icon: CircleDashed,
text: 'text-ink-subtle',
bg: 'bg-surface-sunken border-line',
dot: 'bg-ink-subtle',
},
} as const
export function Status({ kind, label, appearance = 'inline', className }: StatusProps) {
const entry = config[kind]
const Icon = entry.icon
if (appearance === 'dot') {
return (
<span className={cn('inline-flex items-center gap-2 text-sm text-ink', className)}>
<span className={cn('size-2 shrink-0 rounded-full', entry.dot)} aria-hidden="true" />
{label}
</span>
)
}
if (appearance === 'pill') {
return (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium',
entry.bg,
entry.text,
className,
)}
>
<Icon className="size-3.5 shrink-0" aria-hidden="true" />
{label}
</span>
)
}
return (
<span
className={cn('inline-flex items-center gap-1.5 text-sm font-medium', entry.text, className)}
>
<Icon className="size-4 shrink-0" aria-hidden="true" />
{label}
</span>
)
}
components/ui/empty-state.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
import type { ReactNode } from 'react'
import { cn } from '@/lib/cn'
/**
* EmptyState
*
* An empty state is a piece of product writing more than a piece of UI, so the
* component enforces the three parts that make one useful: what is missing,
* why it might be missing, and the single most likely next action.
*/
export interface EmptyStateProps {
icon?: ReactNode
title: string
description?: string
action?: ReactNode
secondaryAction?: ReactNode
/** `panel` draws a dashed enclosure; `bare` sits inside an existing panel. */
appearance?: 'panel' | 'bare'
size?: 'sm' | 'md' | 'lg'
className?: string
}
export function EmptyState({
icon,
title,
description,
action,
secondaryAction,
appearance = 'panel',
size = 'md',
className,
}: EmptyStateProps) {
const padding = { sm: 'py-8', md: 'py-12', lg: 'py-20' }[size]
return (
<div
className={cn(
'flex flex-col items-center px-6 text-center',
padding,
appearance === 'panel' &&
'rounded-lg border border-dashed border-line-strong bg-surface-sunken/60',
className,
)}
>
{icon ? (
<div className="mb-4 flex size-11 items-center justify-center rounded-full border border-line bg-surface text-ink-subtle">
{icon}
</div>
) : null}
<p className="text-md font-semibold text-ink-strong text-balance">{title}</p>
{description ? (
<p className="mt-1.5 max-w-sm text-sm text-ink-muted text-pretty">{description}</p>
) : null}
{(action || secondaryAction) && (
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
{action}
{secondaryAction}
</div>
)}
</div>
)
}
Demo source — adapt to your project. Foundry is not published as a package.
Usage
The most reassuring thing an offline screen can do is be specific about what is still available, so everything that works offline is listed explicitly.
- Subscribe to the browser events; a static offline page that lies about the state is worse than none.
- List what still works, not just what does not.
Variants and states
Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.
- Live connectivity status
- Reload action
- What-still-works list
Accessibility
- Live status
- The status pill updates with real connectivity events.
- Recovery
- Reload is a real button that works the moment the connection returns.
Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.