<script setup lang="ts">
import { ref } from 'vue'
import NotFound from './components/NotFound.vue'
import ServerError from './components/ServerError.vue'
type ErrorView = '404' | '500'
const view = ref<ErrorView>('404')
const views: ErrorView[] = ['404', '500']
// Tabs keyboard pattern: arrows move between tabs.
function onKeydown(e: KeyboardEvent) {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
const i = views.indexOf(view.value)
view.value = views[(i + (e.key === 'ArrowRight' ? 1 : views.length - 1)) % views.length]!
const list = e.currentTarget as HTMLElement
requestAnimationFrame(() => list.querySelector<HTMLElement>('[aria-selected="true"]')?.focus())
}
</script>
<template>
<div class="relative min-h-screen bg-gray-50 dark:bg-gray-950">
<div aria-hidden="true" class="pointer-events-none absolute inset-x-0 top-0 h-96 bg-gradient-to-b from-amber-100/70 to-transparent dark:from-amber-500/10" />
<header class="relative mx-auto flex max-w-5xl items-center justify-between px-5 py-5">
<a href="#" class="flex items-center gap-2 rounded-lg font-semibold text-gray-900 focus-visible:outline-2 focus-visible:outline-amber-500 dark:text-white">
<span class="grid size-8 place-items-center rounded-lg bg-amber-500 text-gray-950" aria-hidden="true">
<svg viewBox="0 0 24 24" class="size-4" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M4 18L12 4l8 14H4z" /></svg>
</span>
Wayfarer
</a>
<!-- Preview switcher: in your app, render the matching component from your router's error handling. -->
<div role="tablist" aria-label="Error page variant" class="flex rounded-lg bg-white p-0.5 text-xs font-semibold shadow-sm ring-1 ring-gray-200 dark:bg-white/5 dark:ring-white/10" @keydown="onKeydown">
<button
v-for="v in views"
:id="`tab-${v}`"
:key="v"
type="button"
role="tab"
:aria-selected="view === v"
:aria-controls="`panel-${v}`"
:tabindex="view === v ? 0 : -1"
class="rounded-md px-3 py-1.5 transition focus-visible:outline-2 focus-visible:outline-amber-500"
:class="view === v ? 'bg-amber-500 text-gray-950' : 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white'"
@click="view = v"
>{{ v === '404' ? '404 Not found' : '500 Error' }}</button>
</div>
</header>
<main :id="`panel-${view}`" role="tabpanel" :aria-labelledby="`tab-${view}`" class="relative">
<NotFound v-if="view === '404'" />
<ServerError v-else />
</main>
</div>
</template>
<template>
<!-- A drawn "404": the zero is a compass whose needle spins lazily. Decorative only. -->
<svg viewBox="0 0 460 200" class="w-full max-w-md" aria-hidden="true">
<defs>
<linearGradient id="nf-digit" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" class="[stop-color:var(--color-amber-400)]" />
<stop offset="1" class="[stop-color:var(--color-orange-500)]" />
</linearGradient>
</defs>
<!-- ground and shadows -->
<ellipse cx="230" cy="186" rx="210" ry="8" class="fill-gray-200 dark:fill-white/5" />
<!-- 4 -->
<path transform="translate(-12 0)" d="M40 150V130l58-100h28v96h18v24h-18v28H98v-28H40zm28-24h30V74L68 126z" fill="url(#nf-digit)" />
<!-- 4 -->
<path transform="translate(12 0)" d="M276 150V130l58-100h28v96h18v24h-18v28h-28v-28h-58zm28-24h30V74l-30 52z" fill="url(#nf-digit)" />
<!-- compass as the 0 -->
<g transform="translate(210 104)">
<circle r="74" class="fill-white stroke-amber-500 dark:fill-gray-900 dark:stroke-amber-400" stroke-width="10" />
<circle r="60" class="fill-none stroke-amber-200 dark:stroke-amber-500/30" stroke-width="2" stroke-dasharray="3 7" />
<g class="fill-gray-400 text-[13px] font-bold dark:fill-gray-500" text-anchor="middle" font-family="ui-sans-serif, system-ui">
<text y="-40">N</text><text y="50">S</text><text x="-45" y="5">W</text><text x="45" y="5">E</text>
</g>
<g class="origin-center [transform-box:fill-box] animate-[spin_6s_ease-in-out_infinite] motion-reduce:animate-none">
<path d="M0-34L9 0H-9z" class="fill-rose-500" />
<path d="M0 34L9 0H-9z" class="fill-gray-300 dark:fill-gray-600" />
</g>
<circle r="6" class="fill-gray-800 dark:fill-gray-200" />
</g>
<!-- little signpost -->
<g transform="translate(430 108)">
<rect x="-3" y="0" width="6" height="78" rx="2" class="fill-amber-800/70 dark:fill-amber-700" />
<path d="M-30 6h44l10 9-10 9h-44z" class="fill-amber-200 dark:fill-amber-500/40" />
<path d="M30 32h-44l-10 9 10 9h44z" class="fill-amber-300 dark:fill-amber-500/60" />
</g>
<!-- dotted trail -->
<path d="M14 176c30-18 60 6 90-4" fill="none" class="stroke-gray-300 dark:stroke-white/15" stroke-width="3" stroke-linecap="round" stroke-dasharray="1 9" />
</svg>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import LostIllustration from './LostIllustration.vue'
export interface HelpfulLink {
title: string
description: string
href: string
icon: string
}
const props = withDefaults(defineProps<{
/** Path the visitor tried to reach; shown so they can spot typos. */
path?: string
links?: HelpfulLink[]
/** Pages the search box suggests from. In a real app, query your search API instead. */
pages?: { title: string; href: string }[]
}>(), {
path: '/blog/2023/annual-reprot',
links: () => [
{ title: 'Documentation', description: 'Guides, API reference and examples.', href: '#', icon: 'M5 4h10l4 4v12H5V4zM9 12h6M9 16h4' },
{ title: 'Blog', description: 'Product news and engineering posts.', href: '#', icon: 'M4 5h16v14H4zM8 9h8M8 13h5' },
{ title: 'Help centre', description: 'Answers to common questions.', href: '#', icon: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM9.5 9a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .9-1 1.6V14M12 17.5v.01' },
{ title: 'Contact us', description: 'Talk to a real person.', href: '#', icon: 'M4 6h16v12H4zM4 7l8 6 8-6' },
],
pages: () => [
{ title: 'Annual report 2023', href: '#' },
{ title: 'Annual report 2022', href: '#' },
{ title: 'Getting started guide', href: '#' },
{ title: 'Pricing', href: '#' },
{ title: 'API reference', href: '#' },
{ title: 'Changelog', href: '#' },
{ title: 'Careers', href: '#' },
{ title: 'Brand and press kit', href: '#' },
],
})
const emit = defineEmits<{ search: [query: string] }>()
const query = ref('')
const results = computed(() => {
const q = query.value.trim().toLowerCase()
if (!q) return []
return props.pages.filter((p) => p.title.toLowerCase().includes(q)).slice(0, 5)
})
</script>
<template>
<section class="mx-auto flex max-w-3xl flex-col items-center px-5 py-16 text-center sm:py-24" aria-labelledby="nf-title">
<LostIllustration />
<p class="mt-10 text-sm font-semibold uppercase tracking-widest text-amber-600 dark:text-amber-400">Error 404</p>
<h1 id="nf-title" class="mt-3 text-3xl font-bold tracking-tight text-balance text-gray-900 sm:text-5xl dark:text-white">This page wandered off the map</h1>
<p class="mt-5 max-w-xl text-base text-pretty text-gray-600 sm:text-lg dark:text-gray-400">
We couldn't find
<code class="rounded-md bg-gray-100 px-1.5 py-0.5 font-mono text-[0.9em] break-all text-gray-800 dark:bg-white/10 dark:text-gray-200">{{ path }}</code>.
It may have moved, or the link has a typo. Try searching instead.
</p>
<form role="search" class="relative mt-8 w-full max-w-md" @submit.prevent="emit('search', query)">
<label for="nf-search" class="sr-only">Search the site</label>
<svg viewBox="0 0 20 20" class="pointer-events-none absolute left-4 top-3.5 size-5 text-gray-400" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" aria-hidden="true"><circle cx="9" cy="9" r="6" /><path d="M13.5 13.5L17 17" /></svg>
<input
id="nf-search"
v-model="query"
type="search"
placeholder="Search pages…"
autocomplete="off"
aria-describedby="nf-search-status"
class="block w-full rounded-xl border border-gray-300 bg-white py-3 pl-11 pr-24 text-sm text-gray-900 shadow-sm placeholder:text-gray-400 focus:border-amber-500 focus:outline-none focus:ring-4 focus:ring-amber-500/15 dark:border-white/15 dark:bg-white/5 dark:text-white dark:placeholder:text-gray-500"
>
<button type="submit" class="absolute right-1.5 top-1.5 rounded-lg bg-gray-900 px-3.5 py-1.5 text-sm font-semibold text-white hover:bg-gray-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 dark:bg-white dark:text-gray-900 dark:hover:bg-gray-200">Search</button>
<p id="nf-search-status" class="sr-only" aria-live="polite">{{ query ? `${results.length} suggestions` : '' }}</p>
<ul v-if="results.length" class="mt-2 overflow-hidden rounded-xl border border-gray-200 bg-white text-left shadow-lg dark:border-white/10 dark:bg-gray-900">
<li v-for="r in results" :key="r.title">
<a :href="r.href" class="flex items-center justify-between px-4 py-2.5 text-sm text-gray-700 hover:bg-amber-50 focus-visible:bg-amber-50 focus-visible:outline-none dark:text-gray-200 dark:hover:bg-white/5 dark:focus-visible:bg-white/5">
{{ r.title }}
<svg viewBox="0 0 20 20" class="size-4 text-gray-400" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" aria-hidden="true"><path d="M7 5l5 5-5 5" /></svg>
</a>
</li>
</ul>
</form>
<ul class="mt-12 grid w-full gap-3 text-left sm:grid-cols-2">
<li v-for="link in links" :key="link.title">
<a :href="link.href" class="group flex items-start gap-4 rounded-2xl border border-gray-200 bg-white p-4 transition hover:border-amber-300 hover:shadow-md hover:shadow-amber-500/5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 dark:border-white/10 dark:bg-white/[0.03] dark:hover:border-amber-400/40">
<span class="grid size-10 shrink-0 place-items-center rounded-xl bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300">
<svg viewBox="0 0 24 24" class="size-5" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path :d="link.icon" /></svg>
</span>
<span class="min-w-0 flex-1">
<span class="block text-sm font-semibold text-gray-900 dark:text-white">{{ link.title }}</span>
<span class="mt-0.5 block text-sm text-gray-600 dark:text-gray-400">{{ link.description }}</span>
</span>
<svg viewBox="0 0 20 20" class="mt-2.5 size-4 text-gray-400 transition group-hover:translate-x-0.5 group-hover:text-amber-600" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" aria-hidden="true"><path d="M7 5l5 5-5 5" /></svg>
</a>
</li>
</ul>
<a href="#" class="mt-10 inline-flex items-center gap-2 text-sm font-semibold text-amber-700 hover:text-amber-600 focus-visible:outline-2 focus-visible:outline-amber-500 dark:text-amber-400">
<svg viewBox="0 0 20 20" class="size-4" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 10H4M9 5l-5 5 5 5" /></svg>
Back to the homepage
</a>
</section>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const props = withDefaults(defineProps<{
/** Reference that support can use to find the failing request in your logs. */
errorId?: string
statusHref?: string
}>(), { errorId: 'req_7f3a9c21e4b8', statusHref: '#' })
const emit = defineEmits<{ retry: [] }>()
const copied = ref(false)
const retrying = ref(false)
async function copyId() {
try {
await navigator.clipboard.writeText(props.errorId)
copied.value = true
setTimeout(() => (copied.value = false), 2000)
} catch {
// Clipboard can be blocked (e.g. insecure context); the ID is still selectable.
}
}
function retry() {
retrying.value = true
emit('retry')
setTimeout(() => (retrying.value = false), 1200)
}
</script>
<template>
<section class="mx-auto flex max-w-lg flex-col items-center px-5 py-20 text-center sm:py-28" aria-labelledby="se-title">
<div class="relative" aria-hidden="true">
<div class="absolute inset-0 rounded-full bg-amber-400/25 blur-2xl" />
<svg viewBox="0 0 96 96" class="relative size-24">
<rect x="14" y="18" width="68" height="22" rx="6" class="fill-white stroke-gray-300 dark:fill-gray-900 dark:stroke-white/15" stroke-width="2" />
<rect x="14" y="46" width="68" height="22" rx="6" class="fill-white stroke-gray-300 dark:fill-gray-900 dark:stroke-white/15" stroke-width="2" />
<circle cx="26" cy="29" r="3" class="fill-emerald-500" />
<circle cx="26" cy="57" r="3" class="fill-rose-500 motion-safe:animate-pulse" />
<path d="M38 29h24M38 57h24" class="stroke-gray-300 dark:stroke-white/20" stroke-width="3" stroke-linecap="round" />
<path d="M62 64l14 14M76 64L62 78" class="stroke-amber-500" stroke-width="4" stroke-linecap="round" />
</svg>
</div>
<p class="mt-8 text-sm font-semibold uppercase tracking-widest text-amber-600 dark:text-amber-400">Error 500</p>
<h1 id="se-title" class="mt-3 text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl dark:text-white">Something broke on our side</h1>
<p class="mt-4 text-gray-600 dark:text-gray-400">It isn't you. Our team has been alerted automatically. Trying again in a moment usually works.</p>
<div class="mt-8 flex w-full flex-col gap-3 sm:w-auto sm:flex-row">
<button type="button" :disabled="retrying" class="inline-flex items-center justify-center gap-2 rounded-xl bg-amber-500 px-5 py-2.5 text-sm font-semibold text-gray-950 shadow-sm transition hover:bg-amber-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 disabled:opacity-70" @click="retry">
<svg viewBox="0 0 20 20" class="size-4" :class="retrying ? 'animate-spin' : ''" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 10a6 6 0 1 1-1.8-4.3M16 4v3.5h-3.5" /></svg>
{{ retrying ? 'Retrying…' : 'Try again' }}
</button>
<a :href="statusHref" class="inline-flex items-center justify-center gap-2 rounded-xl border border-gray-300 px-5 py-2.5 text-sm font-semibold text-gray-800 transition hover:bg-gray-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 dark:border-white/15 dark:text-white dark:hover:bg-white/5">
<span class="size-2 rounded-full bg-amber-500" aria-hidden="true" /> System status
</a>
</div>
<div class="mt-10 flex w-full items-center justify-between gap-3 rounded-xl border border-dashed border-gray-300 px-4 py-3 text-left dark:border-white/15">
<div class="min-w-0">
<p class="text-xs text-gray-500 dark:text-gray-400">Error reference</p>
<p class="truncate font-mono text-sm text-gray-900 select-all dark:text-gray-100">{{ errorId }}</p>
</div>
<button type="button" class="shrink-0 rounded-lg px-2.5 py-1.5 text-xs font-semibold text-amber-700 hover:bg-amber-50 focus-visible:outline-2 focus-visible:outline-amber-500 dark:text-amber-400 dark:hover:bg-white/5" @click="copyId">
<span aria-live="polite">{{ copied ? 'Copied' : 'Copy' }}</span>
</button>
</div>
</section>
</template>