Overview#
Profile is a personal website for engineers and designers, built around a fictional senior frontend engineer, Sam Lindqvist, in Stockholm. It has a home page, a printable résumé, a filterable project gallery with a case study, a writing archive with search, a blog post, a /uses page and a validated contact form, plus a Ctrl/⌘ K command palette and persistent dark mode. This is the Astro version (1.0.1): Astro 7, TypeScript and Tailwind CSS v4, with no UI framework. It builds to static HTML that looks and behaves exactly like the HTML version. Live demo: https://freeframe.dev/templates/profile
Quick start#
Requirements: Node.js 24 or newer and pnpm (npm works too).
pnpm install
pnpm dev # http://localhost:4321With npm: npm install, then npm run dev.
| Command | What it does |
|---|---|
pnpm dev |
Development server at http://localhost:4321 with hot reload |
pnpm build |
Static site in dist/ |
pnpm preview |
Serve dist/ locally after a build |
pnpm check |
astro check: type-checks .astro files and TypeScript |
pnpm typecheck |
Generate Astro's types, then type-check with tsc (TypeScript 7) |
pnpm lint |
ESLint (TypeScript and Astro rules) |
pnpm build:demo |
Build for /demos/astro/profile into demo/ (only used for the demo on freeframe.dev) |
Pages#
| File | URL | Purpose and notable sections |
|---|---|---|
src/pages/index.astro |
/ |
Hero with portrait, "/now" panel, highlight stats, three project cards, latest four posts, call to action |
src/pages/resume.astro |
/resume |
Experience timeline, skill meters, education, certifications, languages; print button |
src/pages/projects.astro |
/projects |
GitHub stats and contribution graph, technology filter, six project cards |
src/pages/project.astro |
/project |
Case study for tinytable: problem, approach, code sample, results |
src/pages/writing.astro |
/writing |
All posts grouped by year, with instant search |
src/pages/post.astro |
/post |
A long-form blog post with code samples |
src/pages/uses.astro |
/uses |
Hardware and software lists, an editor settings snippet |
src/pages/contact.astro |
/contact |
Contact form with validation, copy-email button |
src/pages/404.astro |
any unknown URL | Not-found page (not indexed by search engines) |
Project structure#
profile/
astro.config.mjs site URL, base path, static output as resume.html (not resume/index.html)
package.json scripts and dependencies
pnpm-workspace.yaml keeps this folder a standalone pnpm project; allowed install scripts
tsconfig.json Astro's strict TypeScript preset
eslint.config.mjs ESLint flat config (typescript-eslint + eslint-plugin-astro)
template.json gallery metadata (name, version, pages)
public/img/ avatar.svg, favicon.svg, og-image.svg and og-image.png (copied as-is)
src/
layouts/Layout.astro <html>, <head> metadata, theme script, header, <main>, footer, scripts
components/
SiteHeader.astro logo, main navigation, palette button, theme toggle, mobile menu
SiteFooter.astro name, social links, copyright
ProjectCard.astro a project card (home page and projects page)
PostItem.astro a post row (home page and writing archive)
data/
nav.ts header links
palette.ts pages listed in the command palette
projects.ts project cards and the filter buttons
posts.ts posts, plus date formatting and grouping helpers
resume.ts work history and skill meters
uses.ts the /uses lists
lib/site.ts site name, theme colours and the url() helper
pages/*.astro one file per page (see Pages)
scripts/main.ts palette, theme, menu, reveals, filters, search, print, copy, form
scripts/back-to-top.ts the back-to-top button
styles/global.css Tailwind import, @theme tokens and component classesCustomising#
Brand and colours#
The accent colour is a scale in the @theme block at the top of src/styles/global.css
(--color-accent-50 to --color-accent-900, teal by default). Neutrals use Tailwind's zinc palette.
Replace the accent scale to re-colour links, focus rings, eyebrows and the contribution graph:
@theme {
--color-accent-500: #8b5cf6;
--color-accent-600: #7c3aed;
--color-accent-700: #6d28d9;
}Component classes (.btn-primary, .card, .chip, .link, .nav-link, .field, .kbd...) are in
the @layer components block of the same file. If you change the page background, update
themeColor in src/lib/site.ts (it fills the two theme-color meta tags).
Typography#
Text uses Inter (--font-sans) and labels and code use JetBrains Mono (--font-mono), loaded
from Google Fonts by the <link rel="stylesheet"> in src/layouts/Layout.astro. To change them,
replace that link and the two tokens in @theme.
Content#
Page copy is plain markup in src/pages/*.astro. The name appears in site.name in src/lib/site.ts
(used for og:site_name and <meta name="author">), in the header and footer components and in the
page titles; search and replace "Sam Lindqvist" and sam@example.com across src/.
Repeated content lives in src/data/:
-
Projects:
projectsinsrc/data/projects.ts. The home page shows the first three, the projects page all of them.tagsare the chips and what the filter matches;projectFilterslists the filter buttons shown after "All". Leave outstarsandforksfor client work.ts{ name: "tinytable", slug: "tinytable", gradient: ["#0f766e", "#2dd4bf"], description: "A 3 kB accessible data table component with sorting, sticky headers and keyboard navigation.", tags: ["TypeScript", "Accessibility"], language: { name: "TypeScript", color: "#3178c6" }, stars: "2.1k", forks: "118", }, -
Posts:
postsinsrc/data/posts.ts, newest first. The writing page groups them by year withpostsByYear()and shows the count; the home page shows the first four with the year in the date. The search text is built from the title and topic, so there is nothing else to fill in:ts{ title: "Design tokens that survive a rebrand", date: "2026-06-18", topic: "Design systems", minutes: 9 },Titles may contain inline HTML such as
<code><dialog></code>. -
Résumé:
experienceandskillsinsrc/data/resume.ts. Each skill's meter is labelled "<name> proficiency" for screen readers. -
/uses:
usesinsrc/data/uses.ts, one group per section. -
Command palette:
palettePagesinsrc/data/palette.ts. The two actions ("Toggle dark mode" and "Print résumé") are added after them in the "Command palette" section ofsrc/scripts/main.ts. -
Stats and contribution graph on the projects page: the numbers are plain markup, and the graph (
[data-contrib]) is decorative sample data generated insrc/scripts/main.ts(it isaria-hidden). Replace it with real data or remove it.
Rolling dates. "Full-time roles from January 2027" on the contact page is <span data-months-ahead="n">: src/scripts/main.ts shows the month n months from now and its year, so
the line never goes stale. The month written in the page is shown without JavaScript. For a fixed date, replace the span
with plain text.
Images and artwork#
The portrait is public/img/avatar.svg (shown square and round); replace it with your photo and update
the src in src/pages/index.astro if the extension changes. The project banners are CSS gradients set
by each project's gradient. The favicon is public/img/favicon.svg; the social image is
public/img/og-image.png (1200×630), exported from og-image.svg.
Navigation, header and footer#
The header and mobile menu are src/components/SiteHeader.astro; both menus are rendered from
mainNav in src/data/nav.ts:
export const mainNav = [
{ key: "about", href: "/", label: "About" },
{ key: "resume", href: "/resume", label: "Résumé" },
// ...
] as const;A page highlights its link by passing the matching key to the layout:
<Layout title="…" description="…" current="resume">. To add a page, create src/pages/<name>.astro
with a <Layout>, add it to mainNav if it belongs in the menu, and to palettePages. The footer is
src/components/SiteFooter.astro.
Dark mode#
An inline script in the <head> of src/layouts/Layout.astro (<script is:inline>) adds the dark
class to <html> before the first paint, from the saved choice or, if there is none, the system
setting. The toggle (data-theme-toggle, with aria-pressed) and the palette's "Toggle dark mode"
switch it and save the choice (see Saved data). Until a visitor chooses, the
site follows system changes live. Dark styles use the dark: variant, which follows the .dark class
(@custom-variant dark in global.css). To default to dark, change the condition in the inline script
to t !== "light".
Features#
All behaviour is in src/scripts/main.ts (and src/scripts/back-to-top.ts), imported once by the
layout.
Command palette#
Press Ctrl K or ⌘ K, or click the search button (data-palette-open), to open a native
<dialog> listing the palette pages and actions. Typing filters labels and hints; Up/Down move (and
wrap), Enter opens, Esc or clicking the backdrop closes it, and Ctrl/⌘ K again toggles it. The
input is a combobox that points at the selected option with aria-activedescendant. The keyboard hint
(data-kbd) shows "⌘K" on Apple devices and "Ctrl K" elsewhere. Links go through url(), so they keep
working under a base path.
Printable résumé#
The résumé page has a print button (data-print). The @media print block in global.css hides the
header, footer, dialog and anything with .no-print, and the page uses print: utility classes for a
clean CV. Add print-break-avoid to blocks that shouldn't split across pages. Opening /resume#print
prints once the page has loaded; the palette's "Print résumé" opens that URL from other pages and prints
straight away when you're already on the résumé.
Project filter#
On the projects page, the filter buttons (data-filter, aria-pressed) show only cards whose
data-tags include the tag and update [data-filter-status] ("Showing 2 projects tagged React"), which
is announced to screen readers.
Writing search#
On the writing page, typing in [data-post-search] hides posts whose data-search doesn't contain the
text, hides empty year groups and shows [data-post-empty] when nothing matches.
Copy email#
On the contact page, the copy button (data-copy-email) copies the text of [data-email] and reports in
[data-copy-status]; if copying isn't allowed it selects the address and suggests Ctrl+C / ⌘C.
Scroll reveals#
Elements with reveal fade in as they scroll into view, and everything above them is revealed too, so
a fast jump never leaves gaps. They show immediately with reduced motion and when printing. The hidden
state only applies when the js class is on <html>, so content is visible without JavaScript.
Mobile menu#
The menu button (data-menu-toggle) opens #mobile-menu, updates aria-expanded and its label, closes
with Esc (returning focus to the button) and closes itself on wide screens.
Back to top button#
src/scripts/back-to-top.ts adds a button in the bottom-right corner, styled by the .back-to-top
rules at the end of global.css. It appears after about one screen of scrolling, scrolls to the top
(instantly with reduced motion), moves focus to the main content and is hidden when printing. Add
data-back-to-top-avoid to anything you pin to the bottom of the screen, or data-back-to-top-hide to a
sticky element it must never cover. To remove it, delete the file, its import in Layout.astro and the
CSS rules.
Forms#
The contact form ([data-contact-form] in src/pages/contact.astro) has these fields:
| Field | Rule |
|---|---|
name |
Required |
email |
Required, must look like an email address |
topic |
Required choice |
message |
Required, at least 20 characters (with a live character count in [data-count]) |
Rules and messages are in the rules object in the "Contact form validation" section of
src/scripts/main.ts. Each field shows its message in #<name>-error and gets aria-invalid; the first
problem is focused. On success the form is replaced by [data-form-success] (focused), and "Write
another" (data-form-reset) brings the empty form back.
Nothing is sent. The site is static, so use a form service. To connect it to Formspree, add the
endpoint to the form in contact.astro:
<form data-contact-form novalidate class="grid gap-5" action="https://formspree.io/f/your-id" method="POST">then, in the submit handler in main.ts (below the comment "To send for real"), post the data before
showing success:
fetch(form.action, { method: "POST", body: new FormData(form), headers: { Accept: "application/json" } }).then((res) => {
if (!res.ok) throw new Error("Request failed");
form.hidden = true;
if (success) {
success.hidden = false;
success.focus();
}
});If you deploy with an Astro adapter instead, you can post to your own endpoint in src/pages/api/.
Saved data (localStorage)#
| Key | Value | Purpose |
|---|---|---|
theme |
"dark" or "light" |
The visitor's theme choice |
Any other value is ignored: the site follows the system setting, and the next click on the toggle
overwrites it. If storage is blocked, the toggle still works for the current page. The key is generic; if
other sites share your domain, rename it (for example profile-theme) in src/scripts/main.ts and in
the inline script in Layout.astro. Reset with localStorage.removeItem("theme").
SEO and sharing#
Each page passes title and description to <Layout>; the layout writes the <title>, meta
description, author, canonical URL, og:* tags (og:type is article on the case study and the blog
post) and twitter:card. Canonical and og:url use the clean URL (/resume, not /resume.html), and
og:image is the absolute URL of /img/og-image.png. Both are built from site in
astro.config.mjs, which reads SITE_URL:
SITE_URL=https://sam.example.com pnpm buildThe 404 page passes noindex, which outputs <meta name="robots" content="noindex"> and no canonical.
For a sitemap, add the official @astrojs/sitemap integration. If you add structured data, a Person
schema on the home page fits this template.
Accessibility#
- Skip link,
banner/main/contentinfolandmarks, one<h1>per page and visible accent focus outlines. - Muted text meets WCAG AA contrast in both themes.
- The command palette is a native modal
<dialog>with a combobox and listbox (aria-activedescendant). - The theme toggle and project filters use
aria-pressed; filter results and copy status are announced. - Form errors are linked with
aria-describedby; the success message receives focus. - Code samples that scroll sideways are focusable, so keyboard users can scroll them.
- The decorative contribution graph is hidden from screen readers.
- Reveals, smooth scrolling and transitions respect
prefers-reduced-motion.
When editing, keep one <h1> per page, give new inputs a <label>, and keep aria-current coming from
current rather than hard-coding it.
Building and deploying#
pnpm build # static site in dist/
pnpm preview # preview dist/ locallydist/ is plain HTML, CSS and JavaScript. Pages are written as resume.html rather than
resume/index.html (build.format: "file" in astro.config.mjs), and links point to /resume
without a trailing slash. Netlify, Cloudflare Pages and GitHub Pages serve /resume from
resume.html automatically; Vercel does with cleanUrls (below).
- Netlify / Cloudflare Pages: build command
pnpm build, publish directorydist. - Vercel: import the repository; it detects Astro and static output needs no adapter. Add a
vercel.jsonwith{ "cleanUrls": true }so/resumeservesresume.html. - GitHub Pages: use the official
Astro GitHub Action. For a project site
(
username.github.io/repo), build withDEMO_BASE_PATH=/repo. - Any static host: upload the contents of
dist/. If the host doesn't map/resumetoresume.html, changebuild.formatto"directory"inastro.config.mjs.
To serve the site from a sub-folder, set DEMO_BASE_PATH when building, e.g.
DEMO_BASE_PATH=/profile pnpm build. It becomes Astro's base, and every link written with url()
(including the palette's links) includes it.
demo/ (if present) is the build used for the freeframe.dev demo; you can delete it.
TypeScript 7 setup#
package.json installs two TypeScript packages on purpose. @typescript/native is TypeScript 7 and
provides the tsc that pnpm typecheck runs. typescript is an alias for @typescript/typescript6, the
TypeScript 6 API that astro check and typescript-eslint load. When those tools support TypeScript 7,
replace both with a single typescript@7. tsconfig.json extends Astro's strict preset.
Troubleshooting#
- Links or images break when served from a sub-folder. Build with
DEMO_BASE_PATH, and write internal links and asset paths withurl()fromsrc/lib/site.tsrather than plain"/..."strings. /resumeshows a 404 on my host. The host doesn't map extensionless URLs to.htmlfiles. Setbuild.format: "directory"inastro.config.mjsand rebuild.- Social previews show the wrong domain or no image. Set
SITE_URLto your domain at build time. - The theme flashes on load. Keep the
<script is:inline>in the layout's<head>. Withoutis:inline, Astro would bundle it and run it too late. - A new project doesn't show up under a filter. The filter matches the exact strings in the
project's
tags, so "Typescript" won't match "TypeScript". Add new technologies toprojectFiltersto get a button. - The printed résumé includes the header or footer. The print styles hide them by their roles
(
header[role="banner"],footer[role="contentinfo"]inglobal.css); keep those attributes inSiteHeader.astroandSiteFooter.astro, or addno-printto anything else you want left out. pnpm installskips a build script.pnpm-workspace.yamllists the packages allowed to run install scripts (esbuild,sharp,@tailwindcss/oxide); add any new one there.pnpm typecheckreports missingastro:*types. Run it through the script, which runsastro syncfirst to generate.astro/types.d.ts.
License#
MIT. The code and all included artwork (portrait, favicon and social image) are free for personal and
commercial use; the download includes the license text in LICENSE.txt. Inter and JetBrains Mono are
licensed under the SIL Open Font License.