Freeframe

Documentation

Profile

v1.0.0Updated Nov 11, 2026A developer portfolio with résumé, projects, writing and a ⌘K command palette.

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.

sh
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.png

Other 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:

css
@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:

css
@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: projects in app/data/projects.ts. The first three appear on the home page, all of them on /projects. Open-source projects show stars and forks; 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",
    },

    slug is the label on the card's banner and gradient its two colors. The filter buttons are projectFilters; a project shows under a filter when its tags include it.

  • Posts: posts in app/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 in app/pages/writing.vue when 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) and skills (name and a level from 0 to 100) in app/data/resume.ts. Education, certifications, languages and "Outside work" are in app/pages/resume.vue.

  • Uses: uses in app/data/uses.ts, one { title, items: [{ name, note }] } group per section.

  • Contact topics: topics in app/data/contact.ts.

  • Command palette: palettePages in app/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#

  1. Add a slug to Post, move app/pages/post.vue to app/pages/post/[slug].vue and read the slug with useRoute().params.slug to find the post.

  2. Keep each post's text somewhere the page can look it up (a content field, Markdown files or a CMS), link PostItem to `/post/${post.slug}` and add every post's route to routes in nuxt.config.ts so it is prerendered:

    ts
    const routes = ["/", "/resume", /* … */ ...posts.map((p) => `/post/${p.slug}`)];

    Do the same for projects with ProjectCard and /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).

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 before d.classList.add('dark') to t!=='light'.
  • Remove dark mode: delete <ThemeToggle /> from SiteHeader.vue, the "Toggle dark mode" item in CommandPalette.vue, and the try{…} part of themeScript that adds the dark class.

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:

ts
{ 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).

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."
Email 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:

ts
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:type is article for the case study and the post), the social image and a large Twitter card. The 404 page passes noindex: true.
  • Site-wide tags (language, author, theme-color, favicon) are in app.head in nuxt.config.ts.
  • Canonical URLs and the social image URL are absolute: SITE_URL plus the base path. Set it when you build:
sh
SITE_URL=https://www.your-name.dev pnpm generate

Without 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#

sh
pnpm generate     # static site in .output/public/
pnpm preview      # serve it locally

nuxt 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):

sh
DEMO_BASE_PATH=/me pnpm generate

pnpm 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 tags must contain the filter's name exactly as in projectFilters (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 routes in nuxt.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 themeScript in app.head.script and use the same key in it and in THEME_KEY; clear an old value with localStorage.removeItem("theme").
  • Images break under a sub-folder. Build with DEMO_BASE_PATH, and write public paths with the base path ($config.app.baseURL or useAsset()).
  • pnpm lint can't find .nuxt/eslint.config.mjs. Run pnpm install or npx nuxt prepare first.
  • 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.

Search documentation

Search the guides and every template's documentation