Template · Error pages
404 Not Found Page
Friendly Vue 3 404 page with a drawn compass illustration, live search suggestions and helpful links, plus a compact 500 error variant. Tailwind CSS.
Minimal one-page portfolio template in Vue 3 and Tailwind CSS: intro, selected work with drawn project covers, about, experience and contact.
by Vueplayv1.0.0Updated 3 hours ago
A quiet, type-led personal site for designers and developers who want the work to do the talking. One page, five sections, lots of whitespace and a single violet accent.
Project covers are small drawn scenes built with divs and SVG (a dashboard, phone screens, a brand board, a type specimen, a landing page and a component sheet), so the template looks finished before you have screenshots. Replace any cover with an <img> when you do.
All content lives in src/data.ts: your name, availability, projects, experience and services. The header collapses to a menu on phones, the contact block has a copy-email button, and every section works in light and dark mode.
<script setup lang="ts">
import { person } from './data'
import AboutSection from './components/AboutSection.vue'
import ContactSection from './components/ContactSection.vue'
import ExperienceList from './components/ExperienceList.vue'
import IntroSection from './components/IntroSection.vue'
import SiteNav from './components/SiteNav.vue'
import WorkGrid from './components/WorkGrid.vue'
</script>
<template>
<div id="top" class="min-h-screen scroll-smooth bg-white text-gray-900 antialiased dark:bg-gray-950 dark:text-white">
<SiteNav />
<main class="mx-auto max-w-5xl px-5 sm:px-8">
<IntroSection />
<WorkGrid />
<AboutSection />
<ExperienceList />
<ContactSection />
</main>
<footer class="mx-auto flex max-w-5xl flex-col gap-2 border-t border-gray-200 px-5 py-8 text-sm text-gray-500 sm:flex-row sm:justify-between sm:px-8 dark:border-white/10 dark:text-gray-400">
<p>© 2026 {{ person.name }}</p>
<a href="#top" class="rounded hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-violet-500 dark:hover:text-white">Back to top ↑</a>
</footer>
</div>
</template>
<script setup lang="ts">
import { person, services } from '../data'
</script>
<template>
<section id="about" class="scroll-mt-20 border-t border-gray-200 py-16 sm:py-24 dark:border-white/10" aria-labelledby="about-title">
<div class="grid gap-12 md:grid-cols-[minmax(0,2fr)_minmax(0,3fr)] md:gap-16">
<div>
<h2 id="about-title" class="text-sm font-medium text-gray-500 dark:text-gray-400">About</h2>
<div class="relative mt-8 aspect-square max-w-72 overflow-hidden rounded-3xl bg-violet-100 dark:bg-violet-500/15" aria-hidden="true">
<svg viewBox="0 0 200 200" class="absolute inset-0 size-full">
<circle cx="100" cy="82" r="34" class="fill-violet-300 dark:fill-violet-400/60" />
<path d="M30 200c6-44 36-70 70-70s64 26 70 70Z" class="fill-violet-500 dark:fill-violet-500/80" />
<circle cx="158" cy="40" r="14" class="fill-amber-300 dark:fill-amber-400/80" />
<path d="M20 150c20-10 40-10 60 0" fill="none" stroke-width="3" stroke-linecap="round" class="stroke-violet-300 dark:stroke-violet-300/40" />
</svg>
<span class="absolute bottom-3 left-3 rounded-full bg-white/90 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-950/80 dark:text-gray-200">{{ person.location }}</span>
</div>
</div>
<div>
<p class="text-2xl leading-snug font-medium tracking-tight text-gray-900 sm:text-3xl dark:text-white">
I care about the small details that make software feel trustworthy: clear words, honest defaults and interfaces that work as well with a keyboard as with a mouse.
</p>
<div class="mt-8 space-y-5 leading-relaxed text-gray-600 dark:text-gray-400">
<p>I usually join teams early, when the product is still taking shape. I run the research, sketch and prototype in the browser, then stay on to build the front end in Vue and TypeScript so nothing gets lost in handoff.</p>
<p>Outside client work I draw typefaces, teach an evening course on interface design and spend too much time on a very old bicycle.</p>
</div>
<h3 class="mt-10 text-sm font-medium text-gray-900 dark:text-white">What I can help with</h3>
<ul class="mt-4 grid grid-cols-2 gap-x-6 gap-y-3 text-gray-600 dark:text-gray-400">
<li v-for="service in services" :key="service" class="flex items-center gap-2.5">
<span class="size-1.5 rounded-full bg-violet-500 dark:bg-violet-400" aria-hidden="true" />
{{ service }}
</li>
</ul>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { person } from '../data'
const copied = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined
async function copyEmail() {
try {
await navigator.clipboard.writeText(person.email)
copied.value = true
clearTimeout(timer)
timer = setTimeout(() => { copied.value = false }, 2000)
}
catch {
copied.value = false
}
}
const elsewhere = [
{ label: 'Journal', href: '#' },
{ label: 'Sketchbook', href: '#' },
{ label: 'Open source', href: '#' },
{ label: 'Newsletter', href: '#' },
]
</script>
<template>
<section id="contact" class="scroll-mt-20 border-t border-gray-200 py-16 sm:py-24 dark:border-white/10" aria-labelledby="contact-title">
<h2 id="contact-title" class="text-sm font-medium text-gray-500 dark:text-gray-400">Contact</h2>
<p class="mt-8 max-w-3xl text-3xl leading-tight font-semibold tracking-tight text-balance text-gray-900 sm:text-5xl dark:text-white">
Have a project in mind? I’d love to hear about it.
</p>
<div class="mt-10 flex flex-col gap-3 sm:flex-row sm:items-center">
<a
:href="`mailto:${person.email}`"
class="group inline-flex min-w-0 items-center gap-3 rounded-full bg-violet-600 py-3 pr-6 pl-3 text-white transition hover:bg-violet-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-500"
>
<span class="grid size-9 shrink-0 place-items-center rounded-full bg-white/15" aria-hidden="true">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" class="size-4.5"><rect x="3" y="4.5" width="14" height="11" rx="2" /><path d="m3.5 5.5 6.5 5 6.5-5" /></svg>
</span>
<span class="truncate font-medium">{{ person.email }}</span>
</a>
<button
type="button"
class="inline-flex items-center justify-center gap-2 rounded-full px-5 py-3 text-sm font-medium text-gray-700 ring-1 ring-gray-200 transition hover:ring-gray-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-500 dark:text-gray-200 dark:ring-white/15 dark:hover:ring-white/40"
@click="copyEmail"
>
<svg v-if="!copied" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" class="size-4" aria-hidden="true"><rect x="7" y="7" width="10" height="10" rx="2" /><path d="M13 7V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2" /></svg>
<svg v-else viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 text-emerald-600 dark:text-emerald-400" aria-hidden="true"><path d="m4 10.5 4 4 8-9" /></svg>
<span aria-live="polite">{{ copied ? 'Copied to clipboard' : 'Copy email' }}</span>
</button>
</div>
<p class="mt-6 text-sm text-gray-500 dark:text-gray-400">I reply within two working days. Based in {{ person.location }} (EET), happy to work across time zones.</p>
<ul class="mt-12 flex flex-wrap gap-x-8 gap-y-3">
<li v-for="link in elsewhere" :key="link.label">
<a :href="link.href" class="group inline-flex items-center gap-1 rounded text-gray-700 hover:text-violet-700 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-violet-500 dark:text-gray-300 dark:hover:text-violet-300">
{{ link.label }}
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="size-4 transition group-hover:translate-x-0.5 group-hover:-translate-y-0.5" aria-hidden="true"><path d="M6 14 14 6m-6 0h6v6" /></svg>
</a>
</li>
</ul>
</section>
</template>
<script setup lang="ts">
import { experience } from '../data'
</script>
<template>
<section id="experience" class="scroll-mt-20 border-t border-gray-200 py-16 sm:py-24 dark:border-white/10" aria-labelledby="experience-title">
<div class="flex flex-wrap items-baseline justify-between gap-4">
<h2 id="experience-title" class="text-sm font-medium text-gray-500 dark:text-gray-400">Experience</h2>
<a href="#" class="inline-flex items-center gap-1.5 rounded text-sm font-medium text-violet-700 hover:text-violet-900 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-violet-500 dark:text-violet-300 dark:hover:text-violet-200">
Download CV
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><path d="M10 3v10m-4-4 4 4 4-4M4 16h12" /></svg>
</a>
</div>
<ol class="mt-8 divide-y divide-gray-200 dark:divide-white/10">
<li v-for="role in experience" :key="role.company + role.period" class="grid gap-2 py-6 sm:grid-cols-[10rem_minmax(0,1fr)] sm:gap-8">
<p class="text-sm text-gray-500 tabular-nums sm:pt-0.5 dark:text-gray-400">{{ role.period }}</p>
<div>
<h3 class="font-semibold text-gray-900 dark:text-white">{{ role.title }} <span class="font-normal text-gray-500 dark:text-gray-400">· {{ role.company }}</span></h3>
<p class="mt-1.5 leading-relaxed text-gray-600 dark:text-gray-400">{{ role.description }}</p>
</div>
</li>
</ol>
</section>
</template>
<script setup lang="ts">
import { person } from '../data'
</script>
<template>
<section class="pt-16 pb-20 sm:pt-28 sm:pb-28" aria-labelledby="intro-title">
<p v-if="person.available" class="inline-flex items-center gap-2 rounded-full border border-gray-200 px-3 py-1 text-sm text-gray-600 dark:border-white/10 dark:text-gray-300">
<span class="relative flex size-2" aria-hidden="true">
<span class="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-60 motion-reduce:animate-none" />
<span class="relative inline-flex size-2 rounded-full bg-emerald-500" />
</span>
Available for projects from November
</p>
<h1 id="intro-title" class="mt-8 max-w-4xl text-4xl leading-[1.1] font-semibold tracking-tight text-balance text-gray-900 sm:text-6xl dark:text-white">
I design and build calm, useful software<span class="text-violet-600 dark:text-violet-400">.</span>
</h1>
<p class="mt-6 max-w-2xl text-lg leading-relaxed text-gray-600 sm:text-xl dark:text-gray-400">
I’m {{ person.name.split(' ')[0] }}, a {{ person.role.toLowerCase() }} based in {{ person.location.split(',')[0] }}. For the last nine years I’ve helped small teams turn fuzzy ideas into products people enjoy using, from research through to production code.
</p>
<div class="mt-10 flex flex-wrap items-center gap-3">
<a href="#work" class="inline-flex items-center gap-2 rounded-full bg-gray-900 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-gray-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-500 dark:bg-white dark:text-gray-900 dark:hover:bg-gray-200">
See selected work
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><path d="M10 4v12m-5-5 5 5 5-5" /></svg>
</a>
<a href="#contact" class="rounded-full px-5 py-2.5 text-sm font-medium text-gray-700 ring-1 ring-gray-200 transition hover:ring-gray-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-500 dark:text-gray-200 dark:ring-white/15 dark:hover:ring-white/40">Get in touch</a>
</div>
</section>
</template>
<script setup lang="ts">
import type { CoverKind } from '../data'
defineProps<{ kind: CoverKind }>()
</script>
<template>
<div class="relative grid size-full place-items-center overflow-hidden p-8" aria-hidden="true">
<!-- Dashboard: a small analytics screen -->
<div v-if="kind === 'dashboard'" class="w-full max-w-sm rounded-xl bg-white p-4 shadow-xl shadow-violet-900/10 ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10">
<div class="flex items-center gap-1.5"><span class="size-2 rounded-full bg-gray-200 dark:bg-white/15" /><span class="size-2 rounded-full bg-gray-200 dark:bg-white/15" /><span class="h-2 w-16 rounded-full bg-gray-100 dark:bg-white/10" /></div>
<div class="mt-4 grid grid-cols-3 gap-2">
<div v-for="n in 3" :key="n" class="rounded-lg bg-gray-50 p-2 dark:bg-white/5">
<div class="h-1.5 w-8 rounded-full bg-gray-200 dark:bg-white/15" />
<div class="mt-2 h-3 w-10 rounded bg-gray-800 dark:bg-white/80" :class="n === 2 && 'bg-violet-500 dark:bg-violet-400'" />
</div>
</div>
<div class="mt-3 flex h-20 items-end gap-1.5 rounded-lg bg-gray-50 p-2 dark:bg-white/5">
<div v-for="(h, i) in [30, 45, 38, 60, 52, 75, 68, 90, 80, 96]" :key="i" class="flex-1 rounded-sm" :class="i > 6 ? 'bg-violet-500' : 'bg-violet-200 dark:bg-violet-400/30'" :style="{ height: `${h}%` }" />
</div>
</div>
<!-- Mobile: a phone with an appointment list -->
<div v-else-if="kind === 'mobile'" class="flex items-end gap-4">
<div class="h-56 w-28 rounded-[1.4rem] bg-gray-900 p-1.5 shadow-xl shadow-amber-900/20 dark:bg-black dark:ring-1 dark:ring-white/15">
<div class="flex h-full flex-col gap-1.5 rounded-[1.1rem] bg-white p-2.5 dark:bg-gray-900">
<div class="mx-auto h-1 w-8 rounded-full bg-gray-200 dark:bg-white/15" />
<div class="mt-1 h-2 w-12 rounded bg-gray-800 dark:bg-white/80" />
<div class="rounded-lg bg-amber-400 p-2"><div class="h-1.5 w-10 rounded bg-white/90" /><div class="mt-1 h-1.5 w-6 rounded bg-white/60" /></div>
<div v-for="n in 3" :key="n" class="flex items-center gap-1.5 rounded-lg bg-gray-50 p-1.5 dark:bg-white/5"><span class="size-3 rounded-full bg-amber-200 dark:bg-amber-400/40" /><span class="h-1.5 flex-1 rounded bg-gray-200 dark:bg-white/15" /></div>
</div>
</div>
<div class="h-44 w-24 rounded-[1.2rem] bg-gray-900 p-1.5 opacity-90 shadow-lg dark:bg-black dark:ring-1 dark:ring-white/15">
<div class="grid h-full place-items-center rounded-[0.95rem] bg-amber-50 dark:bg-gray-900">
<div class="grid size-12 place-items-center rounded-full bg-amber-400 text-lg font-bold text-white">✓</div>
</div>
</div>
</div>
<!-- Brand: logo mark, swatches and a label -->
<div v-else-if="kind === 'brand'" class="flex items-center gap-6">
<div class="grid size-28 place-items-center rounded-full bg-emerald-800 shadow-xl shadow-emerald-900/20 dark:bg-emerald-600">
<svg viewBox="0 0 48 48" class="size-14 fill-emerald-50"><path d="M24 8c6 6 9 12 9 18a9 9 0 0 1-18 0c0-6 3-12 9-18Z" /><rect x="10" y="36" width="28" height="4" rx="2" /></svg>
</div>
<div class="space-y-2">
<div class="h-8 w-24 rounded-md bg-emerald-800 dark:bg-emerald-600" />
<div class="h-8 w-24 rounded-md bg-emerald-400" />
<div class="h-8 w-24 rounded-md bg-stone-300 dark:bg-stone-400" />
<p class="pt-1 font-serif text-sm tracking-[0.3em] text-emerald-900 uppercase dark:text-emerald-200">Moss&Stone</p>
</div>
</div>
<!-- Type: a specimen -->
<div v-else-if="kind === 'type'" class="text-center text-gray-900 dark:text-white">
<p class="text-8xl leading-none font-black tracking-tight">Aa</p>
<p class="mt-3 text-sm font-medium tracking-[0.25em] uppercase">Plinth Sans</p>
<div class="mt-3 flex justify-center gap-2 font-mono text-xs text-gray-500 dark:text-gray-400">
<span v-for="w in [300, 400, 500, 700, 900]" :key="w" :style="{ fontWeight: w }">{{ w }}</span>
</div>
</div>
<!-- Landing: browser window with hero -->
<div v-else-if="kind === 'landing'" class="w-full max-w-sm overflow-hidden rounded-xl bg-white shadow-xl shadow-sky-900/10 ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10">
<div class="flex items-center gap-1.5 border-b border-gray-100 px-3 py-2 dark:border-white/10"><span class="size-2 rounded-full bg-rose-300" /><span class="size-2 rounded-full bg-amber-300" /><span class="size-2 rounded-full bg-emerald-300" /><span class="ml-2 h-2 flex-1 rounded-full bg-gray-100 dark:bg-white/10" /></div>
<div class="bg-linear-to-b from-sky-950 to-sky-800 px-5 py-6 text-center">
<div class="mx-auto h-2.5 w-40 rounded bg-white" />
<div class="mx-auto mt-2 h-2.5 w-28 rounded bg-white/80" />
<div class="mx-auto mt-3 h-1.5 w-36 rounded bg-sky-200/50" />
<div class="mx-auto mt-4 h-5 w-16 rounded-md bg-sky-400" />
</div>
<div class="grid grid-cols-3 gap-2 p-3">
<div v-for="n in 3" :key="n" class="h-10 rounded-md bg-gray-50 dark:bg-white/5" />
</div>
</div>
<!-- System: component tiles -->
<div v-else class="grid w-full max-w-xs grid-cols-3 gap-2.5">
<div class="col-span-2 flex items-center gap-2 rounded-lg bg-white p-3 shadow-sm dark:bg-gray-900"><span class="h-6 w-14 rounded-md bg-rose-500" /><span class="h-6 w-14 rounded-md ring-1 ring-rose-300 dark:ring-rose-400/40" /></div>
<div class="grid place-items-center rounded-lg bg-white p-3 shadow-sm dark:bg-gray-900"><span class="relative h-4 w-8 rounded-full bg-rose-500"><span class="absolute top-0.5 right-0.5 size-3 rounded-full bg-white" /></span></div>
<div class="grid place-items-center rounded-lg bg-white p-3 shadow-sm dark:bg-gray-900"><span class="grid size-5 place-items-center rounded bg-rose-500 text-[10px] text-white">✓</span></div>
<div class="col-span-2 rounded-lg bg-white p-3 shadow-sm dark:bg-gray-900"><div class="h-2 w-12 rounded bg-gray-300 dark:bg-white/20" /><div class="mt-2 h-6 rounded-md ring-1 ring-gray-200 dark:ring-white/15" /></div>
<div class="col-span-3 flex gap-1.5 rounded-lg bg-white p-3 shadow-sm dark:bg-gray-900">
<span v-for="c in ['bg-rose-900', 'bg-rose-700', 'bg-rose-500', 'bg-rose-300', 'bg-rose-100']" :key="c" class="h-6 flex-1 rounded" :class="c" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { person } from '../data'
const links = [
{ label: 'Work', href: '#work' },
{ label: 'About', href: '#about' },
{ label: 'Experience', href: '#experience' },
{ label: 'Contact', href: '#contact' },
]
const open = ref(false)
</script>
<template>
<header class="sticky top-0 z-30 bg-white/80 backdrop-blur-md dark:bg-gray-950/80" @keydown.esc="open = false">
<nav class="mx-auto flex h-16 max-w-5xl items-center justify-between px-5 sm:px-8" aria-label="Main">
<a href="#top" class="flex items-center gap-2.5 rounded-md text-sm font-semibold text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-violet-500 dark:text-white">
<span class="grid size-8 place-items-center rounded-full bg-gray-900 text-xs text-white dark:bg-white dark:text-gray-900" aria-hidden="true">{{ person.initials }}</span>
{{ person.name }}
</a>
<ul class="hidden items-center gap-7 sm:flex">
<li v-for="link in links" :key="link.href">
<a :href="link.href" class="rounded text-sm text-gray-600 transition hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-violet-500 dark:text-gray-400 dark:hover:text-white">{{ link.label }}</a>
</li>
</ul>
<button
type="button"
class="-mr-2 grid size-10 place-items-center rounded-full text-gray-700 hover:bg-gray-100 focus-visible:outline-2 focus-visible:outline-violet-500 sm:hidden dark:text-gray-300 dark:hover:bg-white/10"
:aria-expanded="open"
aria-controls="nav-menu"
@click="open = !open"
>
<span class="sr-only">{{ open ? 'Close menu' : 'Open menu' }}</span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" class="size-5" aria-hidden="true">
<path v-if="open" d="M6 6l12 12M18 6 6 18" />
<path v-else d="M4 9h16M4 15h16" />
</svg>
</button>
</nav>
<ul v-show="open" id="nav-menu" class="space-y-1 border-y border-gray-100 px-5 py-3 sm:hidden dark:border-white/10">
<li v-for="link in links" :key="link.href">
<a :href="link.href" class="block rounded-lg px-3 py-2.5 text-base text-gray-800 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-white/10" @click="open = false">{{ link.label }}</a>
</li>
</ul>
</header>
</template>
<script setup lang="ts">
import { projects } from '../data'
import ProjectCover from './ProjectCover.vue'
</script>
<template>
<section id="work" class="scroll-mt-20 py-16 sm:py-20" aria-labelledby="work-title">
<div class="flex items-baseline justify-between gap-4">
<h2 id="work-title" class="text-sm font-medium text-gray-500 dark:text-gray-400">Selected work</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">2023 — 2026</p>
</div>
<ul class="mt-8 grid gap-x-8 gap-y-14 sm:grid-cols-2">
<li v-for="project in projects" :key="project.id">
<article class="group relative">
<div class="aspect-4/3 overflow-hidden rounded-2xl transition duration-500 group-hover:scale-[0.985]" :class="project.tint">
<div class="size-full transition duration-500 group-hover:scale-[1.04] motion-reduce:transition-none">
<ProjectCover :kind="project.cover" />
</div>
</div>
<div class="mt-5 flex items-baseline justify-between gap-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
<a href="#" class="rounded focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-violet-500">
<span class="absolute inset-0" aria-hidden="true" />
{{ project.title }}
</a>
</h3>
<p class="shrink-0 text-sm text-gray-500 tabular-nums dark:text-gray-400">{{ project.year }}</p>
</div>
<p class="mt-2 leading-relaxed text-gray-600 dark:text-gray-400">{{ project.summary }}</p>
<ul class="mt-4 flex flex-wrap gap-2" aria-label="Role">
<li v-for="r in project.role" :key="r" class="rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-700 dark:bg-white/10 dark:text-gray-300">{{ r }}</li>
</ul>
</article>
</li>
</ul>
</section>
</template>
export type CoverKind = 'dashboard' | 'mobile' | 'brand' | 'type' | 'landing' | 'system'
export interface Project {
id: string
title: string
client: string
year: number
summary: string
role: string[]
cover: CoverKind
/** Tailwind background classes for the cover panel. */
tint: string
}
export interface Role {
company: string
title: string
period: string
description: string
}
export const person = {
name: 'Juno Kaskela',
initials: 'JK',
role: 'Product designer & front-end developer',
location: 'Helsinki, Finland',
email: 'hello@junokaskela.example',
available: true,
}
export const projects: Project[] = [
{
id: 'tally',
title: 'Tally analytics',
client: 'Tally',
year: 2026,
summary: 'Redesigned the reporting dashboard for a bookkeeping tool, cutting the time to build a monthly report from 20 minutes to under 3.',
role: ['Product design', 'Vue'],
cover: 'dashboard',
tint: 'bg-violet-100 dark:bg-violet-500/15',
},
{
id: 'harbor',
title: 'Harbor mobile',
client: 'Harbor Health',
year: 2025,
summary: 'A calm appointment app for a network of physiotherapy clinics, designed with patients over 65 in every research round.',
role: ['UX research', 'iOS & Android'],
cover: 'mobile',
tint: 'bg-amber-100 dark:bg-amber-500/15',
},
{
id: 'moss',
title: 'Moss & Stone identity',
client: 'Moss & Stone',
year: 2025,
summary: 'Visual identity and packaging system for a small ceramics studio, from logo to shipping labels.',
role: ['Brand', 'Packaging'],
cover: 'brand',
tint: 'bg-emerald-100 dark:bg-emerald-500/15',
},
{
id: 'plinth',
title: 'Plinth Sans',
client: 'Personal project',
year: 2024,
summary: 'A geometric sans-serif with 6 weights and tabular figures, drawn for dense interfaces and released under an open licence.',
role: ['Type design'],
cover: 'type',
tint: 'bg-stone-200 dark:bg-stone-500/20',
},
{
id: 'orbit',
title: 'Orbit launch site',
client: 'Orbit Labs',
year: 2024,
summary: 'Marketing site and launch campaign for a developer tool, built as a fast static site with interactive product demos.',
role: ['Web design', 'Development'],
cover: 'landing',
tint: 'bg-sky-100 dark:bg-sky-500/15',
},
{
id: 'grain',
title: 'Grain design system',
client: 'Grain',
year: 2023,
summary: 'Tokens, 40 components and documentation for a fintech team of 30 engineers, shipped in Figma and code at the same time.',
role: ['Design systems', 'TypeScript'],
cover: 'system',
tint: 'bg-rose-100 dark:bg-rose-500/15',
},
]
export const experience: Role[] = [
{ company: 'Independent', title: 'Designer & developer', period: '2023 — now', description: 'Product and web work for early-stage teams, usually from first sketch to shipped code.' },
{ company: 'Grain', title: 'Senior product designer', period: '2020 — 2023', description: 'Led design for payments and the design system. Grew the design team from two to seven.' },
{ company: 'Saaristo Studio', title: 'Designer', period: '2017 — 2020', description: 'Websites, brand work and prototypes for cultural institutions and non-profits.' },
{ company: 'Kallio School of Design', title: 'MA, Visual communication', period: '2015 — 2017', description: 'Thesis on typography for small screens, later published as a short handbook.' },
]
export const services = ['Product design', 'Design systems', 'Vue & TypeScript', 'Prototyping', 'Brand identity', 'Workshops']
Template · Error pages
Friendly Vue 3 404 page with a drawn compass illustration, live search suggestions and helpful links, plus a compact 500 error variant. Tailwind CSS.
Template · Dashboards
A complete Vue 3 admin dashboard template with a collapsible sidebar, hand-drawn SVG charts, a sortable customers table and settings. Tailwind CSS, dark mode.
Template · Dashboards
Web analytics dashboard template for Vue 3 and Tailwind CSS: SVG area chart, metric tabs, top pages, sources, a devices donut and a date range picker.
Template · Landing pages
Vue 3 landing page for a mobile app with a CSS-drawn phone mockup, feature highlights, a screenshot strip and download buttons. Tailwind CSS, dark mode.