Overview#
Profile is a personal website template for engineers and designers, shown as the site of Sam Lindqvist, a fictional senior frontend engineer in Berkeley. It has an about page with a "/now" card, a printable résumé, a filterable project gallery with GitHub-style stats, a project case study, a searchable writing archive, a blog post, a /uses page, a contact page with a validated form and a copy-email button, and a 404 page, with a Ctrl/⌘ K command palette and a light and dark theme. This is the Nuxt version (1.0.0): Nuxt 4, Vue 3 single-file components, TypeScript and Tailwind CSS v4, generated as a static site.
Live demo: freeframe.dev/templates/profile
Quick start#
Requirements: Node.js 24 or newer and pnpm (corepack enable pnpm) or npm.
pnpm install # also runs `nuxt prepare` (types and the ESLint config in .nuxt/)
pnpm dev # http://localhost:3000| Script | What it does |
|---|---|
pnpm dev |
Development server at http://localhost:3000 |
pnpm generate |
Static site in .output/public/ (nuxt generate): every page prerendered to HTML |
pnpm build |
A Node.js server build in .output/ (nuxt build), for hosts that run Nuxt as a server |
pnpm preview |
Serve the last build locally |
pnpm lint |
ESLint (@nuxt/eslint) |
pnpm typecheck |
nuxt prepare and vue-tsc |
pnpm build:demo |
Generate for freeframe.dev under /demos/nuxt/profile into demo/ (you can delete it) |
Pages#
| Route | File | Notable sections and features |
|---|---|---|
/ |
app/pages/index.vue |
Introduction with portrait and availability badge, "/now" card, highlights in numbers, three project cards, latest four posts, call to action |
/resume |
app/pages/resume.vue |
Experience timeline, skill meters, education, certifications, languages, print / PDF button and print stylesheet |
/projects |
app/pages/projects.vue |
GitHub stats and contribution graph, technology filter, six project cards |
/project |
app/pages/project.vue |
The tinytable case study: facts, an illustrated table, write-up, code sample, results |
/writing |
app/pages/writing.vue |
Instant search, posts grouped by year, RSS call to action |
/post |
app/pages/post.vue |
An example blog post with code, a quote and tags |
/uses |
app/pages/uses.vue |
Hardware and software lists, an editor settings sample |
/contact |
app/pages/contact.vue |
Contact form with validation, copy-email button, availability, links |
| 404 | app/error.vue |
"This page wandered off" with a terminal illustration |
Like the HTML version, the template has one case study and one blog post: every project card links to /project
and every post to /post. See Giving every post its own page to change that.
Project structure#
app/
├── app.vue <NuxtLayout> and <NuxtPage> (pages restart when a link opens the page already showing)
├── layouts/default.vue skip link, header, the page, footer, command palette, back-to-top button
├── pages/ one file per page (index.vue is the about page)
├── error.vue 404 page (generated as 404.html)
├── components/
│ ├── SiteHeader.vue name, navigation, palette button, theme switch, mobile menu
│ ├── SiteFooter.vue name, keyboard hint, social links, copyright year
│ ├── CommandPalette.vue the Ctrl/⌘ K palette
│ ├── ThemeToggle.vue light/dark switch
│ ├── KbdHint.vue the "Ctrl K" / "⌘K" hint
│ ├── ProjectCard.vue a project card
│ ├── ProjectFilter.vue technology filter and project grid
│ ├── ContributionGraph.vue the GitHub-style graph
│ ├── PostItem.vue, PostTitle.vue a post row and its title
│ ├── WritingArchive.vue writing search and the posts by year
│ ├── ContactForm.vue the contact form
│ ├── CopyEmail.vue email address with a copy button
│ ├── PrintButton.vue "Download / print PDF" and printing on /resume#print
│ ├── MonthsAhead.vue a month that rolls forward from today
│ └── BackToTop.vue, SearchIcon.vue, ArrowIcon.vue
├── composables/
│ ├── usePageSeo.ts title, description, canonical URL and Open Graph tags per page
│ ├── useCurrentPage.ts aria-current for menu links
│ ├── usePalette.ts usePaletteOpen(): the palette's open state
│ ├── useIsMac.ts Apple device check for keyboard hints
│ └── useAsset.ts a file in public/ with the base path
├── data/
│ ├── site.ts name, role, email, navigation, palette pages, social links
│ ├── projects.ts projects and the filter's technologies
│ ├── posts.ts posts and date / search helpers
│ ├── resume.ts experience and skills
│ ├── uses.ts the /uses lists
│ └── contact.ts the contact form's topics
├── utils/
│ ├── theme.ts THEME_KEY, theme labels, setTheme() and toggleTheme()
│ └── safeStorage.ts localStorage with an in-memory fallback
├── plugins/
│ ├── reveal.client.ts scroll reveals
│ ├── same-page.client.ts links to the page already showing restart it at the top
│ └── stable-build.server.ts drops the build timestamp so builds are reproducible
├── router.options.ts writes query strings like the HTML version
└── assets/
├── css/main.css fonts, Tailwind import, accent colors, component classes, print styles
└── fonts/ Inter and JetBrains Mono (woff2) with their licenses
public/img/ avatar.svg, favicon.svg, og-image.svg and og-image.pngOther files: nuxt.config.ts (head, the theme script, base path, SITE_URL, prerendered routes), eslint.config.mjs,
tsconfig.json, template.json (gallery metadata), pnpm-workspace.yaml (marks the folder as a standalone project).
Customizing#
Brand and colors#
The site uses Tailwind's zinc grays with one accent color, a teal scale defined in @theme in
app/assets/css/main.css. Change the scale to re-color links, badges, the timeline, skill meters, the contribution
graph and focus rings:
| Token | Value | Used for |
|---|---|---|
--color-accent-50, -100 |
#f0fdfa, #ccfbf1 |
Badge and success backgrounds, selection |
--color-accent-300, -400 |
#5eead4, #2dd4bf |
Accent text and links in dark mode |
--color-accent-500, -600 |
#14b8a6, #0d9488 |
Underlines, dots, meters, focus rings |
--color-accent-700, -800, -900 |
#0f766e, #115e59, #134e4a |
Accent text in light mode, hover colors |
For example, an indigo accent:
@theme {
--color-accent-50: #eef2ff;
--color-accent-100: #e0e7ff;
--color-accent-300: #a5b4fc;
--color-accent-400: #818cf8;
--color-accent-500: #6366f1;
--color-accent-600: #4f46e5;
--color-accent-700: #4338ca;
--color-accent-800: #3730a3;
--color-accent-900: #312e81;
}The reusable classes (.btn, .card, .chip, .field, .nav-link, .eyebrow, .muted, .prose-body...) are in
the @layer components block of main.css. The call to action on the home page has its own glow
(rgb(45_212_191/.25)), and each project banner has its own gradient (see Content). The theme-color
values are in app.head.meta in nuxt.config.ts.
Typography#
The site uses Inter (400 to 700) for text and JetBrains Mono (400 and 500) for dates, labels and code. They're
self-hosted from app/assets/fonts/ with @font-face rules at the top of main.css: the same Latin files Google
Fonts serves, declared at the same weights, so text wraps exactly like the HTML version. --font-sans and
--font-mono in @theme use the family names.
To swap one, put another woff2 file in app/assets/fonts/, point its @font-face rule at it and change the family
name in --font-sans or --font-mono:
@font-face {
font-family: "Geist";
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url("../fonts/Geist-latin.woff2") format("woff2");
}Content#
Your name, role, location and email are site in app/data/site.ts; the page texts (introduction, "/now",
highlights, the case study, the post, contact details) are written in the page files. Lists live in typed data files:
-
Projects:
projectsinapp/data/projects.ts. The first three appear on the home page, all of them on/projects. Open-source projects showstarsandforks; leave them out for client work.ts{ name: "fetch-cache", slug: "fetch-cache", gradient: ["#1e3a8a", "#60a5fa"], description: "A tiny stale-while-revalidate cache for fetch().", tags: ["TypeScript", "Performance"], language: { name: "TypeScript", color: "#3178c6" }, stars: "240", forks: "12", },slugis the label on the card's banner andgradientits two colors. The filter buttons areprojectFilters; a project shows under a filter when itstagsinclude it. -
Posts:
postsinapp/data/posts.ts, newest first. Wrap code in backticks in a title ("The quiet power of the `<dialog>` element"); it's shown as<code>and searched as plain text. The home page shows the latest four and the archive groups them by year. The writing page's intro says "9 posts so far"; update it inapp/pages/writing.vuewhen you add posts.ts{ title: "Why I still write CSS by hand", date: "2026-10-01", topic: "CSS", minutes: 5 }, -
Résumé:
experience(role, company, dates, summary, highlights) andskills(name and a level from 0 to 100) inapp/data/resume.ts. Education, certifications, languages and "Outside work" are inapp/pages/resume.vue. -
Uses:
usesinapp/data/uses.ts, one{ title, items: [{ name, note }] }group per section. -
Contact topics:
topicsinapp/data/contact.ts. -
Command palette:
palettePagesinapp/data/site.ts(see Command palette).
Rolling dates. "Full-time roles from January 2027" on the contact page uses <MonthsAhead :n="3" fallback="January 2027" /> (app/components/MonthsAhead.vue), which shows the month three months from now and its
year. The generated HTML shows the fallback and the browser the rolling date, so the line never goes stale. Add
format="short" for "Feb 2027" or :day="15" for "February 15, 2027". For a fixed date, write it as plain text
instead. The copyright year in the footer is the current year in the same way.
Giving every post its own page#
-
Add a
slugtoPost, moveapp/pages/post.vuetoapp/pages/post/[slug].vueand read the slug withuseRoute().params.slugto find the post. -
Keep each post's text somewhere the page can look it up (a
contentfield, Markdown files or a CMS), linkPostItemto`/post/${post.slug}`and add every post's route toroutesinnuxt.config.tsso it is prerendered:tsconst routes = ["/", "/resume", /* … */ ...posts.map((p) => `/post/${p.slug}`)];Do the same for projects with
ProjectCardand/project.
Images and artwork#
The portrait is public/img/avatar.svg (square, shown at 176–224px on the home page and 32px on the post); replace it
with a square photo of at least 480×480 and keep a real alt text. Project banners are CSS gradients with a subtle
grid, so there's nothing to replace; to use screenshots instead, put an <img> in the banner of ProjectCard.vue and
keep its 9rem height. The case study's table illustration is HTML in app/pages/project.vue. The social image is
public/img/og-image.png (1200×630), exported from og-image.svg; the favicon and the header logo are
public/img/favicon.svg. Files in public/ are linked with the base path (`${$config.app.baseURL}img/avatar.svg`
in templates, useAsset() in scripts).
Navigation, header and footer#
The menu is mainNav in app/data/site.ts; SiteHeader.vue renders it for desktop and mobile and marks the current
page with aria-current="page" (on its own route and on the routes in also, so the case study marks Projects and the
post marks Writing). To add a page, create app/pages/<route>.vue, add its route to routes in nuxt.config.ts, add
it to mainNav and, if you like, to palettePages. The footer (app/components/SiteFooter.vue) renders social from
app/data/site.ts; replace the placeholder addresses with your profiles. The email icon links to /contact.
Dark mode#
The theme is the dark class on <html>; Tailwind's dark: variant follows it (@custom-variant dark in
main.css). A small script (themeScript in nuxt.config.ts, added to <head> with app.head.script) applies it
before the page paints, so there's no flash: a stored "dark" or "light" wins, and anything else (no choice yet, or
an unexpected value) follows the system setting. As soon as the page is parsed it also gives the theme button its
pressed state and label ("Switch to light mode" / "Switch to dark mode") and the keyboard hints their Mac form. Until
the visitor picks a theme, the site also follows the system setting when it changes. ThemeToggle.vue and the
palette's "Toggle dark mode" call toggleTheme() (app/utils/theme.ts), which switches the class and saves the
choice.
- Start in dark mode regardless of the system: in
themeScript, change the condition befored.classList.add('dark')tot!=='light'. - Remove dark mode: delete
<ThemeToggle />fromSiteHeader.vue, the "Toggle dark mode" item inCommandPalette.vue, and thetry{…}part ofthemeScriptthat adds thedarkclass.
Features#
Command palette#
Press Ctrl K (⌘ K on a Mac) anywhere, or use the search button in the header, to open CommandPalette.vue: a
native <dialog> with a filter field and a list of pages (palettePages in app/data/site.ts) and two actions,
"Toggle dark mode" and "Print résumé". Typing filters by label and hint; ↑/↓ move, Enter or a click runs the
item, and Esc, Ctrl/⌘ K or a click on the backdrop close it. Pages open with a full page load, like a link in the
HTML version. "Print résumé" prints straight away on the résumé page and otherwise opens /resume#print, which prints
once the page has loaded. The open state is shared with usePaletteOpen(), so any component can open it. The hints in
the header, footer, post and 404 page say "⌘K" on Apple devices and "Ctrl K" elsewhere (KbdHint.vue, useIsMac()).
Add an action by adding an item with an action to ITEMS in CommandPalette.vue:
{ label: "Email me", hint: "Action", action: () => window.location.assign("mailto:sam@example.com") },Accessibility: the field is a combobox with aria-activedescendant pointing at the selected option; the dialog keeps
focus inside and returns it when it closes; opening the palette closes the mobile menu.
Project filter#
ProjectFilter.vue on /projects: "All" and one button per technology in projectFilters, as toggle buttons
(aria-pressed). The grid shows the projects whose tags include the chosen technology, and the status line
("Showing 2 projects tagged CSS") is a polite live region.
Contribution graph#
ContributionGraph.vue draws 52 weeks of days in five levels (.cg-0 to .cg-4 in main.css), from a seeded
generator, so it's the same on every build and decorative (aria-hidden). The numbers above it are written in
app/pages/projects.vue. To show real activity, replace levels() with your data (one level from 0 to 4 per day,
oldest first, weeks of seven days).
Writing search#
WritingArchive.vue on /writing: typing in the search field filters the posts by title and topic (case
insensitive, plain text, see searchText() in app/data/posts.ts), hides years with no matches and shows "No posts
match that search." when nothing matches. Clearing the field shows everything again.
Résumé printing#
The "Download / print PDF" button (PrintButton.vue) opens the browser's print dialog; choose "Save as PDF" for a PDF.
The @media print block in main.css hides the header, footer, palette, buttons and anything with the no-print
class, removes card borders and padding, and keeps entries with print-break-avoid on one page. Utilities with the
print: prefix (on /resume) adjust spacing for paper. Opening /resume#print prints on load.
Contact form and copy email#
See Forms for the form. "Email me directly" (CopyEmail.vue) shows the address with a copy button: it
copies site.email and says "Copied to clipboard." in a polite live region; if the clipboard is blocked, it selects
the address and asks the visitor to press Ctrl+C (⌘C on a Mac). The message clears after a few seconds.
Mobile menu, scroll reveals and focusable code#
Below 768px the menu button shows the links under the header bar (aria-expanded, label "Open menu"/"Close menu",
icons swap); Escape closes it and returns focus to the button, and it closes after following a link, when the palette
opens or when the window gets wider. Elements with the reveal class fade in as they scroll into view
(app/plugins/reveal.client.ts), and everything before them too; they're only hidden while JavaScript runs (the
js class the head script adds), and everything shows at once with reduced motion. Don't put reveal on an element
with a :class binding: Vue would remove the is-visible class the plugin adds. Code samples
(<pre tabindex="0"> on the case study, the post and /uses) can be focused and scrolled with the keyboard.
Back to top#
BackToTop.vue appears after you scroll about one screen, scrolls back to the top (instantly with reduced motion) and
moves focus to <main>. Remove it by deleting <BackToTop /> in app/layouts/default.vue and the .back-to-top
styles in main.css.
Forms#
The contact form (ContactForm.vue, on /contact) checks:
| Field | Rule | Message |
|---|---|---|
| Name | Not empty | "Please tell me your name." |
| Not empty, looks like an address | "Please enter your email address." / "That email address doesn’t look right." | |
| Topic | Chosen | "Please choose a topic." |
| Message | At least 20 characters | "Please write a message." / "A little more detail, please (at least 20 characters)." |
A field is checked when it loses focus (once it has a value) and again as you type while it's invalid; each has an
error line (<field>-error) linked with aria-describedby, and aria-invalid once checked. Submitting checks every
field and focuses the first problem. The message field counts characters. When everything is valid, the form is
replaced by a thank-you panel, which gets focus; "Write another" brings back an empty form. The rules and their
messages are RULES in ContactForm.vue, and the topics are in app/data/contact.ts. The thank-you panel promises a
reply within two business days, like the intro on the page, and shows a short reference (SL- and four characters
with no 0/O or 1/I, like SL-7K2Q, made in the browser by makeRef()). Use your form service's own reference instead if
it returns one, and change the reply time in both places if yours differs.
Nothing is sent until you connect the form. To receive messages with a form service such as
Formspree, send the form data in onSubmit before showing the thank-you panel:
async function onSubmit() {
// … validation as before …
const res = await fetch("https://formspree.io/f/your-form-id", {
method: "POST",
body: new FormData(form.value!),
headers: { Accept: "application/json" },
});
if (!res.ok) return; // show an error message here
reference.value = makeRef(); // or the reference your service returns
sent.value = true;
await nextTick();
success.value?.focus();
}With pnpm build (a Nuxt server instead of a static site) you can post to your own server route instead, for
example server/api/contact.post.ts with defineEventHandler and readBody, called with $fetch("/api/contact", { method: "POST", body: values }).
Saved data (localStorage)#
| Key | Value | Read by |
|---|---|---|
theme (THEME_KEY) |
"dark" or "light" |
themeScript in nuxt.config.ts (before paint) and ThemeToggle.vue |
Only the exact values "dark" and "light" are used; anything else is ignored and the system setting applies (and is
followed when it changes). The value is written when the visitor uses the theme switch or the palette's "Toggle dark
mode", through safeStorage (app/utils/safeStorage.ts): if storage is blocked, the choice lasts for the page view
and the next page follows the system again. To reset it, run localStorage.removeItem("theme") in the browser console
or clear the site's data. If another site on the same domain also uses the key theme, give this one its own name in
THEME_KEY and in themeScript.
SEO and sharing#
- Each page calls
usePageSeo({ title, description })(app/composables/usePageSeo.ts) with its full title ("Projects — Sam Lindqvist"): it sets the description, a canonical link, Open Graph tags (og:typeisarticlefor the case study and the post), the social image and a large Twitter card. The 404 page passesnoindex: true. - Site-wide tags (language, author,
theme-color, favicon) are inapp.headinnuxt.config.ts. - Canonical URLs and the social image URL are absolute:
SITE_URLplus the base path. Set it when you build:
SITE_URL=https://www.your-name.dev pnpm generateWithout it they point at https://samlindqvist.example. Consider adding a sitemap and robots.txt (a static
public/robots.txt works), an RSS feed for the "RSS feed" link (built from posts), and
Person structured data (JSON-LD, with useHead) on the home page.
Accessibility#
Built in: a skip link to <main>, landmarks (banner, main and mobile navigation, main, contentinfo), one h1 per page,
visible focus rings, aria-current in the navigation, a theme switch with aria-pressed and a label that says what it
will do, an accessible mobile menu, a command palette built on the native <dialog>, filter toggle buttons and a live
status line, skill meters with role="meter" and values, labeled lists, form errors linked with aria-describedby
and focus moved to the first error or to the thank-you panel, a live region for the copy status, focusable code
samples, decorative icons and illustrations hidden from screen readers, and reduced motion respected for reveals,
animations and scrolling. Colors meet WCAG AA in both themes.
When editing, keep one h1 per page, label every form field, give the portrait and any photos real alt text, and
check contrast in both themes when you change the accent.
Building and deploying#
pnpm generate # static site in .output/public/
pnpm preview # serve it locallynuxt generate prerenders every route listed in routes in nuxt.config.ts as <page>.html (resume.html,
linked as /resume), plus 404.html from app/error.vue. Next to each page is a folder with its _payload.json,
which Nuxt loads when you move between pages. Upload .output/public/ to any static host (Netlify, Cloudflare Pages,
GitHub Pages, S3, Vercel). Hosts that don't map /resume to resume.html on their own need that rewrite (Vercel:
"cleanUrls": true); most serve 404.html for unknown URLs.
To serve the site from a sub-folder, set DEMO_BASE_PATH, which becomes Nuxt's app.baseURL (links, assets and the
command palette follow it):
DEMO_BASE_PATH=/me pnpm generatepnpm build makes a Node.js server build instead (run it with node .output/server/index.mjs); the pages are still
prerendered, and you can add server routes. demo/ is the freeframe.dev demo build and can be deleted.
TypeScript setup#
pnpm typecheck runs vue-tsc against the configs Nuxt writes to .nuxt/ (tsconfig.json references them).
typescript is aliased to @typescript/typescript6, the TypeScript 6 API that vue-tsc and typescript-eslint load;
replace it with typescript@7 once they support it.
Troubleshooting#
- A new project doesn't show under a filter. Its
tagsmust contain the filter's name exactly as inprojectFilters(case matters). - A post title shows backticks. Use one pair per code part; an odd number of backticks leaves one unpaired.
- A new page is missing from the generated site. Add its route to
routesinnuxt.config.ts. - "Print résumé" prints a page with the header. Print from
/resume; the print styles hide the site chrome (header[role="banner"],footer[role="contentinfo"],.no-print), so keep those roles and classes. - The theme flashes or ignores the switch. Keep
themeScriptinapp.head.scriptand use the same key in it and inTHEME_KEY; clear an old value withlocalStorage.removeItem("theme"). - Images break under a sub-folder. Build with
DEMO_BASE_PATH, and write public paths with the base path ($config.app.baseURLoruseAsset()). pnpm lintcan't find.nuxt/eslint.config.mjs. Runpnpm installornpx nuxt preparefirst.- The form shows its thank-you message but nothing arrives. It sends nothing until you connect it (see Forms).
License#
Freeframe Commercial License: use it in unlimited websites and apps for yourself and your clients; it may not
be resold or redistributed as a template. The full terms, with your licensee details, are in LICENSE.txt and
at https://freeframe.dev/license#commercial. Third-party components keep their own licenses. The fonts are
licensed under the SIL Open Font License (see app/assets/fonts/). Sam Lindqvist, the companies and the
projects are fictional.