Freeframe

Documentation

Profile

v1.0.4Updated Oct 17, 2026A developer portfolio with résumé, projects, writing and a ⌘K command palette.

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

sh
pnpm install
pnpm dev          # http://localhost:4321

With 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 classes

Customising#

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:

css
@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: projects in src/data/projects.ts. The home page shows the first three, the projects page all of them. tags are the chips and what the filter matches; projectFilters lists the filter buttons shown after "All". Leave out stars and forks for 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: posts in src/data/posts.ts, newest first. The writing page groups them by year with postsByYear() 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>&lt;dialog&gt;</code>.

  • Résumé: experience and skills in src/data/resume.ts. Each skill's meter is labelled "<name> proficiency" for screen readers.

  • /uses: uses in src/data/uses.ts, one group per section.

  • Command palette: palettePages in src/data/palette.ts. The two actions ("Toggle dark mode" and "Print résumé") are added after them in the "Command palette" section of src/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 in src/scripts/main.ts (it is aria-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.

The header and mobile menu are src/components/SiteHeader.astro; both menus are rendered from mainNav in src/data/nav.ts:

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.

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:

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

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

sh
SITE_URL=https://sam.example.com pnpm build

The 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/contentinfo landmarks, 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#

sh
pnpm build        # static site in dist/
pnpm preview      # preview dist/ locally

dist/ 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 directory dist.
  • Vercel: import the repository; it detects Astro and static output needs no adapter. Add a vercel.json with { "cleanUrls": true } so /resume serves resume.html.
  • GitHub Pages: use the official Astro GitHub Action. For a project site (username.github.io/repo), build with DEMO_BASE_PATH=/repo.
  • Any static host: upload the contents of dist/. If the host doesn't map /resume to resume.html, change build.format to "directory" in astro.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 with url() from src/lib/site.ts rather than plain "/..." strings.
  • /resume shows a 404 on my host. The host doesn't map extensionless URLs to .html files. Set build.format: "directory" in astro.config.mjs and rebuild.
  • Social previews show the wrong domain or no image. Set SITE_URL to your domain at build time.
  • The theme flashes on load. Keep the <script is:inline> in the layout's <head>. Without is: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 to projectFilters to 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"] in global.css); keep those attributes in SiteHeader.astro and SiteFooter.astro, or add no-print to anything else you want left out.
  • pnpm install skips a build script. pnpm-workspace.yaml lists the packages allowed to run install scripts (esbuild, sharp, @tailwindcss/oxide); add any new one there.
  • pnpm typecheck reports missing astro:* types. Run it through the script, which runs astro sync first 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.

Search documentation

Search the guides and every template's documentation