Freeframe

Documentation

Profile

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

Overview#

Profile is a complete personal website for engineers and designers, built as a WordPress block theme. The fictional owner is Sam Lindqvist, a senior frontend engineer in Stockholm. The theme has a home page with a /now section, highlights, selected work and the latest writing, a printable résumé, a project gallery filtered by technology with GitHub-style stats and a contribution graph, a project case study, a writing archive grouped by year with instant search, a blog post, a /uses page, a contact page with a validated form and a copy-email button, and a 404 page, in light and dark themes. A Ctrl/⌘ K command palette jumps to any page. It is version 1.0.1 (see template.json and style.css), works with WordPress 7.1 and PHP 7.4 or newer, and is the WordPress version of the HTML template of the same name. See it live at freeframe.dev/templates/profile.

Everything you see is made of core WordPress blocks arranged in block patterns, so you edit text, links, buttons, lists and images in the block editor without touching code. Colours, the fonts and font sizes are design tokens in theme.json: change them in Appearance > Editor > Styles and the whole site follows.

Quick start#

Requirements: WordPress 7.1 or newer and PHP 7.4 or newer. No plugins are needed.

  1. Download profile.zip from freeframe.dev/templates/profile (the WordPress tab).
  2. In your WordPress admin go to Appearance > Themes > Add New Theme > Upload Theme, choose the zip and click Install Now, then Activate. The theme is listed as Freeframe Profile.
  3. Go to Appearance > Profile setup and click Create the demo pages. This creates the Home, Résumé, Projects, case study, Writing, blog post, Uses and Contact pages from the theme's page patterns, sets Home as the front page (Settings > Reading) and creates the header menu. Pages that already exist with the same slug are left alone, so it is safe to click it again.
  4. In Settings > General, set the Site Title to your name and the Tagline to your role: the header shows the title, and the home page's browser title is "Title — Tagline".
  5. Visit your site. Edit any page in Pages, and the header, footer and templates in Appearance > Editor.

You can also try the theme without installing anything: the Try it in WordPress button on freeframe.dev opens it in WordPress Playground (WordPress running in your browser) with the demo pages already set up.

To work on the theme's files locally you need Node.js 24 and pnpm; WordPress itself runs in WordPress Playground, so there is no PHP, web server or database to install:

sh
pnpm install
pnpm dev     # builds the CSS, then starts WordPress with the theme at http://127.0.0.1:9400

pnpm dev signs you in as admin (password password) and creates the demo pages. The site is rebuilt from scratch every time you start it; your edits to theme files are live, edits made in the WordPress admin are not kept.

Pages#

Each page is a page pattern (in Patterns > Profile pages) made of section patterns. Every section is also offered on its own in the block inserter under Patterns > Profile.

Page Slug Page pattern Sections (pattern files in patterns/)
Home / page-home.php home-hero (introduction and portrait), now, highlights, selected-work, latest-writing, home-cta
Résumé /resume/ page-resume.php resume-header (name, contact details, print button), resume-body (summary, experience, skills, education, certifications, languages)
Projects /projects/ page-projects.php projects-header, github-activity (stats and contribution graph), project-list (technology filter and cards)
tinytable case study /project/ page-project.php case-study
Writing /writing/ page-writing.php writing-header, writing-search, writing-archive (entries by year), rss
The quiet power of the dialog element /post/ page-post.php post-article
Uses /uses/ page-uses.php uses-header, uses-list, uses-settings (a code sample)
Contact /contact/ page-contact.php contact-header, contact-form (form, email, availability, links)
404 any missing URL templates/404.html 404

The case study and the blog post are examples: duplicate their page (Pages, hover the page, Duplicate with a plugin, or copy all blocks into a new page) for each project or article, and link to it from the Projects and Writing pages. WordPress posts use templates/single.html (see Writing); the blog, archives and search results use templates/index.html, templates/archive.html and templates/search.html.

Project structure#

freeframe-profile/
  style.css                 theme header (name, version, requirements, license); no styles
  theme.json                design tokens: palette, fonts, font sizes, layout widths, button
  functions.php             loads the files in inc/
  inc/setup.php             stylesheet and script (with their strings and page URLs), editor
                            styles, dark mode script, theme colour, 404 noindex, pattern categories
  inc/icons.php             registers assets/icons/*.svg for the Icon block
  inc/icon-labels.php       the icons' names shown in the editor
  inc/block-styles.php      button block styles
  inc/site-setup.php        Appearance > Profile setup: creates the demo pages and the menu
  templates/                page.html (pages), page-with-title.html, single.html, index.html,
                            archive.html, search.html, 404.html
  parts/                    header.html and footer.html (they show the header and footer patterns)
  patterns/                 every section and page (PHP files with block markup)
  assets/css/theme.css      the compiled stylesheet (do not edit: built from src/)
  assets/css/editor.css     the same for the block editor (generated by src/editor-css.mjs)
  assets/js/main.js         the site's JavaScript
  assets/js/editor.js       block editor tweak (removes WordPress's Outline button style)
  assets/icons/             SVG icons for the Icon block
  assets/img/               portrait (avatar.svg), favicon, social image
  assets/fonts/             Inter and JetBrains Mono and their licenses (OFL.txt)
  src/theme.css             the Tailwind CSS source of assets/css/theme.css
  src/icons.css             icons drawn before or after text with CSS (generated)
  src/editor-css.mjs        writes assets/css/editor.css after each build
  languages/                freeframe-profile.pot, the template for translations
  blueprint.json            WordPress Playground setup used by `pnpm dev`
  screenshot.png            the picture shown in Appearance > Themes
  package.json              the CSS build and the local WordPress (Node.js tooling)
  readme.txt, README.md, DOCS.md, CHANGELOG.md, LICENSE.txt

Customising#

Brand and colours#

The colours are in theme.json (settings.color.palette) and in Appearance > Editor > Styles > Colors > Palette:

Colours Used for
Accent 50 to Accent 900 the teal of eyebrows, links' underlines, the contribution graph, focus rings, the "open to roles" badge
Zinc 50 to Zinc 950, White, Black the neutrals of text, cards, borders and buttons
Base and Contrast the page background and text; they switch to dark values in dark mode

The theme's stylesheet reads the Accent colours from the palette, so changing Accent 700 in Styles recolours every eyebrow and accent at once. Pick a full ramp (light 50 to dark 900) so hover states and dark mode keep their contrast. In theme.json:

json
{ "slug": "accent-700", "name": "Accent 700", "color": "#0f766e" }

The neutrals used by the design itself come from Tailwind's zinc scale in the stylesheet; the Zinc palette entries are the same colours for your own blocks. The dark mode values of Base and Contrast are set in src/theme.css (html.dark { --wp--preset--color--base: ... }).

Typography#

The fonts are Inter (body and headings, weights 400 to 700) and JetBrains Mono (dates, chips, eyebrows and code, 400 to 500), bundled in assets/fonts/ and declared in theme.json (settings.typography.fontFamilies), so no request goes to Google. To use other fonts, install them in Appearance > Editor > Styles > Typography > Fonts (the Font Library) and select them, or replace the fontFace entries in theme.json. The theme's utility classes use the families --wp--preset--font-family--inter (font-sans) and --wp--preset--font-family--mono (font-mono), so rename those slugs only together with src/theme.css.

Font sizes are the presets in theme.json (xs 0.75rem to 9xl 8rem, the Tailwind scale). The stylesheet uses them too: changing Large in Styles changes every text-lg in the design.

Content#

Edit pages in Pages: click any text to change it, select a button to change its link, select an image and use Replace. Sections are groups; open List View (the stacked lines icon at the top left) to select a section, move it, duplicate it or delete it. To add a section from another page, use the block inserter (+) > Patterns > Profile. Search the site for "Sam Lindqvist", "Northwind" and sam@example.com when you make it yours: they appear in the pages, the header (the Site Title), the footer and the case study.

Some content is in Custom HTML blocks, because blocks can't express it: edit those in the block's HTML view.

Pattern Custom HTML What it is
header the command palette, dark mode and menu buttons buttons driven by assets/js/main.js
resume-header the "Download / print PDF" button prints the page
github-activity the contribution graph's colour legend decorative
project-list the technology filter buttons see Project filter
case-study the table illustration an illustration built in HTML; replace it with an Image block
writing-search the search field see Writing search
contact-form the form, its thank-you message, the email address and copy button see Forms

A few blocks carry a class (Block settings > Advanced > Additional CSS class(es)) that the script reads. Blocks can't store data- attributes, so the theme writes them as classes and the first lines of assets/js/main.js turn them back into attributes: ffd-name--value becomes data-name="value" (an underscore stands for a space), ffa-name--value becomes the attribute name="value" (ffa-role--meter, ffa-aria-valuenow--95), and ff-hidden hides a block until the script shows it. The ones you will meet:

  • ffd-tags--React,CSS on a project card (an Article group in project-list): the technologies the filter matches, comma-separated without spaces.
  • ffd-post on a writing entry (a list item) and ffd-year-group on each year's section: the search reads them. See Writing search.
  • ffd-contrib on the empty group the contribution graph is drawn in.
  • reveal fades a block in as it scrolls into view; link-card on a group makes the whole group clickable through its first link.

To add a project, duplicate a card in List View (in project-list and, if you like, in selected-work on the home page), change its name, link, description, technology chips and stats, and set its ffd-tags--... class. Add a filter button for a new technology in the filter's Custom HTML (data-filter="Rust", the same spelling as in the tags).

Rolling dates. "Full-time roles from January 2027" in the contact form pattern is <span data-months-ahead="n">: assets/js/main.js 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#

  • Portrait: assets/img/avatar.svg in an Image block on the home page and the blog post. Replace it with your photo from the Media Library (square, at least 448 × 448 px); the round crop comes from the block's classes.
  • Logo: the header shows assets/img/favicon.svg in an Image block next to the Site Title block. Replace the image in Appearance > Editor > Patterns > Header; the name comes from Settings > General > Site Title.
  • Project covers are coloured gradients drawn by the card's classes (for example [background:linear-gradient(135deg,#0f766e,#2dd4bf)] on the cover group); change the colours in Additional CSS class(es), then run pnpm build if you use new ones, or put an Image block in the cover group instead.
  • Icons are Icon blocks: select one and pick another icon from the Profile collection (the theme's icons) or WordPress's own. Icons inside a link or button (the arrows, the social icons in the footer) are drawn by CSS: the class icon-after-arrow-right adds one; the available names are in src/icons.css.
  • Social image: assets/img/og-image.png (1200 × 630, source og-image.svg). WordPress doesn't output Open Graph tags by itself; see SEO and sharing.
  • Favicon: assets/img/favicon.svg is used until you set a Site Icon in Settings > General.

The header and footer are template parts (parts/header.html, parts/footer.html) showing the header and footer patterns. Edit them in Appearance > Editor > Patterns > Header / Footer.

The header has two Navigation blocks showing the same menu: the desktop one and the one in the mobile menu. Edit the menu once in Appearance > Editor > Navigation (the setup creates "Main menu" with About (the home page), Résumé, Projects, Writing, Uses and Contact). A Navigation block that doesn't point at a menu shows the most recently created one; to pin a menu, select the Navigation block and choose it in the block settings, for both blocks. WordPress marks the link to the current page; the case study and the blog post mark Projects and Writing (their sections carry the classes ff-section-projects and ff-section-writing, read by assets/js/main.js).

The footer's social links are Button blocks with the Icon only style (the text stays for screen readers) and the class icon-before-github, icon-before-linkedin, icon-before-mastodon or icon-before-email. The copyright year is added by PHP.

Dark mode#

The switch in the header toggles the dark class on <html> and remembers the choice in localStorage (key theme, value dark or light). Without a saved choice (or with any other stored value) the visitor's system setting decides, and the page follows it when it changes. A small script printed first in <head> (freeframe_profile_theme_script() in inc/setup.php) applies the class before the page is drawn, so there is no flash. The command palette's "Toggle dark mode" does the same as the switch.

To always show the light theme, remove the switch from the header pattern and add this to a child theme's functions.php:

add_filter( 'freeframe_profile_dark_mode', '__return_false' );

Dark mode styles are the dark: classes on blocks; colours picked in the editor's colour panels are the same in both modes, except Base and Contrast.

Features#

Command palette#

Press Ctrl K or ⌘ K, or click the search button in the header (data-palette-open), to open a native <dialog> listing pages and actions. Typing filters the labels and hints; Up and Down move, Enter opens, Esc or a click on the backdrop closes it. The keyboard hints (<span class="kbd" data-kbd> in the footer and in text) show "⌘K" on Apple devices and "Ctrl K" elsewhere.

The entries are PAGES in the "Command palette" part of assets/js/main.js. Their links use the site's page URLs, printed by freeframe_profile_page_urls() in inc/setup.php (by the HTML template's file names: resume.html is the page with the slug resume):

js
var PAGES = [
  { label: __('About'), hint: __('Home page'), href: pageUrl('index.html') },
  { label: __('Résumé'), hint: __('Experience, skills, education'), href: pageUrl('resume.html') },
  // ...
  { label: __('Toggle dark mode'), hint: __('Action'), action: toggleTheme }
];

Add an entry with href for a page (a full URL works too), or action for a command, and add its label and hint to freeframe_profile_script_strings() to make them translatable. Make the change in a child theme so updates don't overwrite it (see Child themes).

Printable résumé#

The "Download / print PDF" button (data-print) prints the page. The print styles at the end of the components in src/theme.css hide the header, footer and anything with the class no-print, and the résumé's print: classes give a clean one- or two-page CV. Add print-break-avoid to blocks that shouldn't be split across pages. Opening /resume/#print prints after the page loads; the palette's "Print résumé" opens it from other pages and prints straight away on the résumé.

The skill bars are groups with ffa-role--meter and ffa-aria-valuenow--95 classes (the script turns them into role="meter" and aria-valuenow="95") and an inner group whose width is the class [width:95%]. Change both numbers together.

Project filter#

On the Projects page, the filter buttons (data-filter, aria-pressed) show only the cards whose ffd-tags--... class includes the technology, and update the line "Showing 2 projects tagged React" (ffd-filter-status, announced to screen readers). The contribution graph is decorative sample data drawn by assets/js/main.js in the empty ffd-contrib group; replace it with your own picture or remove the section.

On the Writing page, typing in the search field hides the entries that don't match, hides empty years and shows the "No posts match that search." message (ffd-post-empty). Each entry's search text is its title and topic, read from the entry when the page loads (the HTML version writes it in a data-search attribute): the title is the first text in the entry's link, the topic is the text before "·" in the last one ("HTML · 6 min" → "html"). So an entry you add or rename is found without any extra setting. Curly apostrophes (WordPress typography) are searched as straight ones.

To add an entry, duplicate a list item in a year's list, change the date, the title, the topic and reading time, and the link. For a new year, duplicate a year section and change its heading.

Writing: pages or posts#

The writing archive and the blog post are pages, edited like any other content. This keeps the demo exactly like the HTML version (an archive grouped by year with instant search, entries with dates and topics) and keeps everything in one place. Each article is its own page: duplicate the blog post page, write the article, and add an entry to the Writing page (and to Latest notes on the home page).

If you prefer WordPress posts (categories, tags, comments, feeds, the post list in the admin), publish them in Posts: templates/single.html uses the same article layout as the demo post (breadcrumb-style category, title, author and date, the same text styles, tags as chips). Show them with a Query Loop block, for example on the Writing page in place of the archive, or set a posts page in Settings > Reading (it uses templates/index.html). The instant search only works with the Writing page's list.

Copy email#

On the Contact page, the copy button (data-copy-email, in a Custom HTML block with the address) copies the address and says so under it (ffd-copy-status); where copying isn't allowed it selects the address and suggests Ctrl+C / ⌘C.

Scroll reveals, mobile menu and back to top#

  • Blocks with the reveal class fade in as they scroll into view, and everything above them is revealed too. They show immediately with reduced motion, without JavaScript and when printing.
  • 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.
  • A "Back to top" button appears after about one screen of scrolling; it scrolls to the top (instantly with reduced motion) and moves focus to the main content. It is created by the "Back to top" part of assets/js/main.js; to remove it, delete that part and the .back-to-top styles in src/theme.css, then run pnpm build.

Block styles#

In the block settings' Styles panel:

Block Styles
Button Fill (default, dark), Secondary (outlined), Light (on dark), Outline (on dark), Icon only (WordPress's own Outline style is replaced by Secondary)

Code samples are Code blocks (they can be scrolled with the keyboard: the class ffa-tabindex--0 makes them focusable), and the 404 page's terminal is a Preformatted block.

Forms#

The contact form (a Custom HTML block in contact-form) 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)

The rules and messages are in the rules object in the "Contact form validation" part of assets/js/main.js (the messages are translated in inc/setup.php). Each field shows its message under it and gets aria-invalid; the first problem is focused. On success the form is replaced by the thank-you message (focused), and "Write another" brings the empty form back. Nothing is sent: it is a demo form until you connect it.

Option 1: a form plugin#

The usual way on WordPress. Install a form plugin (WPForms, Gravity Forms, Fluent Forms, Contact Form 7, Jetpack Forms...), build the form there, then on the Contact page delete the two Custom HTML blocks of the form and its thank-you message and insert the plugin's block or shortcode in their place. The plugin sends the messages and stores them. To make its fields look like the theme's, add the class field to fields and btn btn-primary to the submit button where the plugin allows custom classes.

Option 2: a form service#

Keep the theme's form and send it to a service such as Formspree:

  1. Create a form on the service and copy its endpoint URL.

  2. Edit the Contact page, open the form's Custom HTML block and add the endpoint to the form tag:

    html
    <form data-contact-form novalidate class="grid gap-5" action="https://formspree.io/f/your-id" method="POST">
  3. In assets/js/main.js (in a child theme, see below), in the form's submit handler, post the data before showing the thank-you message:

    js
    fetch(form.action, { method: 'POST', body: new FormData(form), headers: { Accept: 'application/json' } })
      .then(function (res) {
        if (!res.ok) throw new Error('Request failed');
        form.hidden = true;
        success.hidden = false;
        success.focus();
      });

    Make this change in a child theme (copy assets/js/main.js there and load it instead), or it will be overwritten when you update the theme.

Saved data (localStorage)#

Key Value Used for
theme "dark" or "light" the visitor's dark mode choice; any other value is ignored and the system setting is used

The key is generic; if other sites share your domain, rename it (for example profile-theme) in assets/js/main.js and in freeframe_profile_theme_script() in inc/setup.php. Clear it from the browser's developer tools (Application > Local Storage).

SEO and sharing#

Page titles come from WordPress: the page title, an em dash and the site name (for example "Projects — Sam Lindqvist"); the home page shows the site name and tagline (Settings > General). The 404 page tells search engines not to index it (freeframe_profile_robots() in inc/setup.php). The theme doesn't output meta descriptions or Open Graph tags; use an SEO plugin such as Yoast SEO, Rank Math or The SEO Framework, and give it assets/img/og-image.png (or your own 1200 × 630 image) as the default social image. WordPress already generates a sitemap at /wp-sitemap.xml and, for posts, a feed at /feed/ (point the RSS button on the Writing page at it if you publish posts).

Accessibility#

Built in: a skip link to the main content (added by WordPress, styled by the theme), landmarks (header, navigation with labels, main, footer), one h1 per page, visible focus rings, a labelled dark mode switch with aria-pressed, a command palette with combobox and listbox roles, the mobile menu button with aria-expanded, filter buttons with aria-pressed and a live status, skill bars as meters, focusable code samples, form errors announced through aria-describedby and aria-invalid, alt text on the portrait, and reduced-motion support for every animation. The demo passes axe (WCAG 2.1 A and AA) in light and dark mode.

When you edit: keep one Heading 1 per page, give images alt text (or mark them decorative), keep link text meaningful, and label any field you add to a form (<label for="...">).

Building and deploying#

You only need this if you change the theme's files. The stylesheet assets/css/theme.css is compiled with Tailwind CSS from src/theme.css and the classes used in patterns/, parts/, templates/, inc/ and assets/js/:

sh
pnpm install
pnpm build   # compile assets/css/theme.css and assets/css/editor.css
pnpm watch   # recompile assets/css/theme.css on every change (run pnpm build before you commit)

Run pnpm build after adding a utility class to a pattern or template, otherwise the class has no style. Classes you type in the editor's Additional CSS class(es) field are only styled if the stylesheet already contains them.

To deploy, zip the theme folder (named freeframe-profile, without node_modules/) and upload it in Appearance > Themes, or copy the folder to wp-content/themes/ on your server.

Translations#

The theme is translation-ready with the text domain freeframe-profile. All text in patterns, templates and PHP goes through WordPress's translation functions; the script's strings (the command palette, labels, form errors) are translated in PHP (freeframe_profile_script_strings() in inc/setup.php). Create a translation with Loco Translate, or with WP-CLI and Poedit:

sh
wp i18n make-pot wp-content/themes/freeframe-profile wp-content/themes/freeframe-profile/languages/freeframe-profile.pot

Save freeframe-profile-de_DE.mo (for example) in wp-content/languages/themes/ or in the theme's languages/ folder. Page content created by Profile setup is copied into your pages in the site's language at that moment; translate it in the editor like any other content. The filter buttons keep their English data-filter values; translate only their text.

Child themes#

Use a child theme for changes you want to keep when Profile is updated. Create wp-content/themes/profile-child/style.css:

css
/*
Theme Name: Profile Child
Template: freeframe-profile
*/

Templates, parts, patterns and theme.json in the child theme override the parent's files of the same name. The parent's stylesheet only contains the classes the parent uses: if your child theme's patterns use new utility classes, compile a stylesheet for the child (copy package.json and src/theme.css, point the @source lines at the child's folders) and enqueue it. To change the script (the palette's entries, the form), copy assets/js/main.js into the child theme and load it in place of the parent's with wp_deregister_script( 'freeframe-profile' ) and wp_enqueue_script() on wp_enqueue_scripts (priority 30), keeping the inline data the parent attaches (window.freeframeProfileL10n, window.freeframeProfilePages).

Troubleshooting#

  • The pages look unstyled or new classes do nothing. The stylesheet only contains classes it found when it was built. Run pnpm build after editing patterns, then reload.
  • "This block contains unexpected or invalid content". A pattern or page was edited by hand with markup that doesn't match the block. Click Attempt recovery, or re-insert the section from Patterns > Profile.
  • The header menu shows other pages, or an old menu. A Navigation block without a chosen menu shows the most recent one. Select it and choose "Main menu" (or yours) in the block settings, in both the desktop and the mobile Navigation block.
  • A new project doesn't show up in a filter. Its ffd-tags--... class must contain the filter's data-filter value exactly (same capitals, commas without spaces).
  • The command palette opens a missing page. The palette links to the pages with the demo's slugs (/resume/, /projects/...); if you renamed a page, change its slug back or edit freeframe_profile_page_urls() in a child theme.
  • Profile setup says the pages exist, but they are empty. It never overwrites existing pages. Delete (and empty the trash of) the pages with those slugs, or open each page and insert its page pattern from Patterns > Profile pages.
  • The contact form doesn't send anything. It is a demo form; see Forms.
  • The theme can't be installed on an older WordPress. Profile uses the Icon block's icon collections from WordPress 7.1; update WordPress first.
  • A page shows the title twice or no title. Pages use a template without a title, because the page patterns start with their own heading. For a plain text page (privacy policy, terms), choose the Page with title template in the page's settings.

Differences from the HTML version#

The pages, copy, design and interactions are the same. What WordPress changes:

  • Straight apostrophes typed in text blocks are shown curly ("Northwind’s"), as WordPress does everywhere.
  • The footer's social links keep their names as hidden text for screen readers (the HTML version uses aria-label), and their icons are drawn with CSS masks.
  • Data and ARIA attributes on content blocks are written as classes and restored by the script (see Content); the writing entries' search text is read from their text.
  • A few labels the HTML version puts on lists (aria-label="Technologies") are not kept on List blocks.

License#

MIT, which is compatible with the GPL that WordPress requires (see LICENSE.txt in the theme folder). Free for personal and commercial use. The portrait and other artwork are included under the same license; the GitHub, LinkedIn and Mastodon marks belong to their owners. Inter and JetBrains Mono are licensed under the SIL Open Font License 1.1 (assets/fonts/*/OFL.txt). Sam Lindqvist is fictional.

Search documentation

Search the guides and every template's documentation