Mimir is a free, open-source portfolio template built for developers, designers, researchers and students who want a clean, fast, bilingual personal site without writing any source code.
You clone the repo, edit two files (.env and config/projects.json), drop your images into a folder, and you have a deployable site. The whole thing is built on top of Vite + React + TypeScript with a "liquid glass" visual style, scroll-driven animations, project carousels per category, light / dark / colorblind themes, optional background music and full English / Spanish support out of the box.
It is named after Mímir, the Norse god of knowledge and wisdom.
The goal of Mimir is to give the community a portfolio that is:
- Free to use: MIT licensed, no attribution required.
- Easy to fork: content lives in config files, not in the React tree.
- Easy to extend: well-documented architecture, small surface area, no hidden magic.
- Community-driven: issues, pull requests and discussions are welcome. If you add a feature, fix a bug, translate it into a new language or build a new theme, please contribute it back.
If you build something on top of Mimir, open an issue or PR and tell us about it. The more variations the community ships, the better the template gets.
The default production bundle is around 150–200 KB gzip. Here's why each piece is in the stack:
| Layer | Tool | Why |
|---|---|---|
| Language | TypeScript 5 | Type-safe React, autocomplete in config loaders |
| Bundler | Vite 5 | Fast dev server, small static build |
| UI | React 18 | Component model, hooks, suspense |
| Styles | Tailwind CSS 3 + custom liquid-glass utilities | Utility classes, theme via CSS variables |
| Animations | Framer Motion 11 | Scroll-driven reveals, springs, gestures |
| Carousel | embla-carousel-react + embla-carousel-auto-scroll |
Accessible image carousel with auto-scroll |
| i18n | i18next + react-i18next + i18next-browser-languagedetector |
Drop-in EN / ES, browser detection |
| Optional backend | Supabase (@supabase/supabase-js) |
Only used if you wire up forms / analytics |
| Deploy | GitHub Pages via gh-pages and GitHub Actions |
One-click public hosting |
| Package manager | pnpm (via Corepack) | Fast installs, strict node_modules |
| Task runner | GNU Make | Cross-OS shortcuts (make dev, make build, …) |
Every visible string in Mimir exists twice (once in English, once in Spanish) and the visitor can switch between them with a single click in the navbar.
- The default language comes from
DEFAULT_LANGUAGEin.env. - The visitor's browser locale wins if it matches a supported language.
- Project titles, descriptions, details and category labels use paired
_en/_esfields inconfig/projects.json. - UI strings (buttons, section headers, tooltips) live in
src/i18n/locales/en.jsonandsrc/i18n/locales/es.json. - Adding a third language is a four-step process: see Section 3.8.
- Requirements
- Start the project
- Customize the site
- Useful commands
- Deploy
- Architecture
- Contributing
- License
You need three things installed on your machine. If you already have them, skip to Section 2.
- Node.js 20 or newer: download from https://nodejs.org
- pnpm: installed once via Corepack (see below)
- Git: download from https://git-scm.com
Run these two commands once. They work on macOS, Linux and Windows:
corepack enablecorepack prepare pnpm@latest --activate
Verify the installation:
pnpm --version
Pick the section that matches your OS. Every command goes on its own line, so copy them one by one.
git clone https://github.com/<your-user>/mimir.gitcd mimircp .env.example .envpnpm installpnpm dev
Then open http://localhost:5173 in your browser.
git clone https://github.com/<your-user>/mimir.gitcd mimircp .env.example .envpnpm installpnpm dev
Then open http://localhost:5173 in your browser.
git clone https://github.com/<your-user>/mimir.gitcd mimirCopy-Item .env.example .envpnpm installpnpm dev
Then open http://localhost:5173 in your browser.
git clone https://github.com/<your-user>/mimir.gitcd mimircopy .env.example .envpnpm installpnpm dev
Then open http://localhost:5173 in your browser.
git clone https://github.com/<your-user>/mimir.gitcd mimircp .env.example .envmake installmake dev
Then open http://localhost:5173 in your browser.
Note: the dev server hot-reloads. Edits to
.envrequire a server restart; edits toconfig/projects.json, images and source files apply instantly.
All site content lives in two files:
.env: your personal info, theme colors, social links.config/projects.json: categories, projects, skills, icon mapping.
You never need to touch source code to change content.
- Copy the template (you already did this in Section 2).
- Open
.envin your editor. - Fill in your values.
| Variable group | What it controls |
|---|---|
OWNER_* |
Your name, title, avatar path, bio (English and Spanish) |
DEFAULT_LANGUAGE |
en or es (visitor's browser still wins when it matches) |
ANIMATIONS |
true / false, toggle scroll-driven animations |
DEFAULT_THEME |
light, dark, or system |
LIGHT_* / DARK_* |
Full color palette per theme |
CB_* |
Okabe-Ito safe accents used when colorblind mode is on |
SOCIAL_* |
Links to GitHub, LinkedIn, email, X, Instagram (empty = hidden) |
MUSIC_* |
Background music toggle, file path, default volume |
Restart the dev server after editing .env.
Each category becomes its own page section, with a heading, a short description, an auto-derived skills row and a project grid below.
Categories live in the "categories" array at the top of config/projects.json. Each entry looks like this:
{
"id": "ai",
"enabled": true,
"label_en": "AI Development",
"label_es": "Desarrollo IA",
"description_en": "Designing and shipping AI systems end to end…",
"description_es": "Diseño y despliegue de sistemas con IA…"
}- Add a category: append a new object with a unique
id. The section appears automatically when at least one project uses thatidin itscategoryfield. - Remove a category: delete the object from the array. Or set
enabledtofalseto hide it without losing the data. Hidden categories also hide every project that belongs to them. - Reorder categories: change the order of the objects in the array.
- Rename a category: edit its
label_enandlabel_es.
Each project lives in the "projects" array. Each entry looks like this:
{
"id": "my-project",
"category": "ai",
"title_en": "My Project",
"title_es": "Mi Proyecto",
"description_en": "Short summary in English.",
"description_es": "Resumen corto en español.",
"details_en": "Optional longer explanation shown when the visitor clicks Show more.",
"details_es": "Explicación más larga opcional, mostrada al hacer clic en Ver más.",
"tools": ["React", "Python"],
"links": [{ "label": "GitHub", "url": "https://github.com/you/my-project" }],
"imageFolder": "my-project"
}- Add a project: append a new object. Make sure:
idis unique.categorymatches an existing categoryid.imageFoldermatches a folder you create underpublic/projects/.
- Remove a project: delete the object from the array. Also delete the corresponding folder under
public/projects/if you no longer need the images. - Bilingual fields: every
_enfield needs a matching_escounterpart. details_en/details_esare optional. If both are empty or omitted, the Show more button still expands the modal (revealing the image carousel, tools, description and links) but no extra paragraphs render. Drop in long-form copy whenever you have it.tools: each name automatically feeds the per-category skills row and the global Skills section. No manual upkeep.links: each link is rendered as a glass button at the end of the description.
Drop image files into public/projects/<imageFolder>/:
public/projects/my-project/
├── 01-hero.jpg
├── 02-detail.webp
└── 03-screenshot.png
- Folder name must match the
imageFolderfield of the corresponding project. - File names can be anything. Images appear in the carousel sorted alphabetically, so prefix with
01_,02_, … to control the sequence. - Supported formats:
.jpg,.jpeg,.png,.webp,.avif. - Add an image: drop the file in the folder. In dev mode it appears instantly; for production rebuild with
pnpm build. - Remove an image: delete the file. The carousel updates automatically.
- Aspect ratio: the carousel renders at 16:9 with
object-cover, so wide screenshots and full-bleed shots look best. Anything narrower will be cropped.
The Skills section at the end of the page groups every tool you mention across your projects, plus any extras you list manually.
Three fields in config/projects.json control it:
skillGroups: the buckets and their order. Bilingual labels:
"skillGroups": [
{ "id": "languages", "label_en": "Languages", "label_es": "Lenguajes" },
{ "id": "ai", "label_en": "AI / ML", "label_es": "IA / ML" }
]skillGroupOf: maps each skill name to its bucket id. Skills with no mapping fall into an auto-generated "Other" bucket so nothing is ever lost:
"skillGroupOf": {
"Python": "languages",
"PyTorch": "ai",
"Cursor": "tools"
}skillsExtra: skills you want to display but haven't shipped a project around yet:
"skillsExtra": ["Rust", "Cursor", "Figma"]Skills used in projects (tools[]) and skills in skillsExtra are deduplicated and merged automatically.
Drop SVG (or PNG / WebP) icons into public/icons/, named after the slugified skill name:
public/icons/react.svg
public/icons/node-js.svg ← "Node.js"
public/icons/cpp.svg ← "C++"
Slug rules: lowercase; spaces, . and / become -; ++ becomes pp; # becomes sharp.
If the natural slug is awkward, add an override under "skillIcons" in config/projects.json:
"skillIcons": {
"Node.js": "nodejs",
"Apollo Federation": "apollo"
}Skills with no icon file just render as text, and nothing breaks.
Suggested sources: Devicon and Simple Icons. See public/icons/README.md for the full details.
- Drop an audio file in
public/audio/, e.g.track.mp3. - In
.env, setVITE_MUSIC_FILE=/audio/track.mp3. - In
.env, setVITE_MUSIC_ENABLED=trueto show the toggle in the navbar.
The audio always starts paused; the visitor decides whether to enable it.
- Create
src/i18n/locales/<code>.json(copyen.jsonas a starting point). - Register it in
src/i18n/index.ts(resourcesandsupportedLngs). - For every entry in
config/projects.json, add the matchingtitle_<code>,description_<code>,details_<code>,label_<code>fields. - Update
localizedProjectandvisibleCategoriesinsrc/config/projects.tsto read the new fields.
Every command assumes you have already run pnpm install (or make install). Each command is on its own line, so copy them one by one.
pnpm devmake dev
Opens the site at http://localhost:5173 with hot reload.
pnpm buildmake build
Outputs an optimized static site to dist/.
pnpm previewmake preview
Serves whatever is in dist/ so you can sanity-check before deploying.
pnpm exec tsc -b --noEmitmake typecheck
Useful in CI or as a pre-commit check.
rm -rf dist node_modules/.vite(macOS / Linux)Remove-Item -Recurse -Force dist, node_modules\.vite(Windows PowerShell)make clean
rm -rf node_modules pnpm-lock.yaml && pnpm install(macOS / Linux)Remove-Item -Recurse -Force node_modules, pnpm-lock.yaml; pnpm install(Windows PowerShell)
pnpm run deploymake deploy
- Push your fork to
main. - In your repo: Settings → Pages → Build and deployment → Source: GitHub Actions.
- Add your
.envto repository secrets so the build sees your real values (see below). - The workflow in .github/workflows/deploy.yml builds and publishes on every push to
main. It setsVITE_BASE_PATHto/<repo-name>/automatically.
Your site will be at https://<username>.github.io/<repo-name>/.
.env is gitignored (good, since it may hold Supabase keys, social links, etc.), so the workflow can't see it at build time. To inject it:
- Open <https://github.com/`
/`/settings/secrets/actions>. - Click New repository secret.
- Name:
ENV_FILE_MIMIR. - Secret: paste the full contents of your local
.envfile (everyVITE_*=...line). - Save.
The workflow writes that secret to .env at the start of every build. If the secret isn't set, it falls back to .env.example so the build doesn't break, but the site will show placeholder content.
You only need to update the secret when you change .env. Adding a project, image, skill or editing source code does not require touching the secret; those changes are read from config/projects.json, public/, and the source tree, which are committed and pushed normally.
Run pnpm run deploy (or make deploy). This builds and pushes dist/ to the gh-pages branch.
If you deploy to a base path different from
/mimir/, setVITE_BASE_PATHin.env.
Run pnpm build and upload the contents of dist/ to Netlify, Vercel, Cloudflare Pages, S3, or any static host.
Mimir is a static site. There is no server, no database (unless you opt in to Supabase). Everything you see on the page comes from three sources read at build time:
- Environment variables (
.env) → loaded by Vite and exposed underimport.meta.env.VITE_*. config/projects.json→ imported as plain JSON and normalized bysrc/config/projects.ts.public/→ served verbatim (images, icons, audio, avatar).
mimir/
├── .env.example # template, copy to .env
├── Makefile # cross-OS shortcuts for install / dev / build / deploy
├── README.md # this file (English)
├── README.es.md # Spanish version
├── index.html # Vite entry HTML
├── package.json # scripts and dependencies
├── pnpm-lock.yaml # locked dependency tree (commit this)
├── tailwind.config.ts # theme tokens, CSS variable bridges
├── vite.config.ts # Vite + base path config
├── tsconfig*.json # TypeScript projects (app + node)
├── config/
│ └── projects.json # categories, projects, skills, icon overrides
├── docs/ # logo, screenshots, written assets
├── public/ # static files served as-is
├── src/ # application source
├── supabase/ # optional backend (forms, analytics), safe to delete
└── .github/workflows/ # GitHub Pages deploy workflow
public/
├── projects/ # one folder per project, name = imageFolder
│ └── <imageFolder>/
│ ├── 01-hero.jpg
│ └── 02-detail.webp
├── icons/ # skill icons (slug-based filenames)
├── audio/ # optional background music
└── avatar.jpg # your avatar (referenced from .env)
Anything in public/ is copied straight to the build output. Use absolute paths (e.g. /avatar.jpg) to reference these files from .env.
src/
├── main.tsx # React entry, mounts <App /> and i18n
├── App.tsx # top-level layout, section composition
├── vite-env.d.ts # typed import.meta.env
├── config/ # config loaders, read .env and projects.json
├── components/ # React components, grouped by purpose
│ ├── layout/ # navbar, footer, page chrome
│ ├── sections/ # hero, categories, skills, contact
│ ├── project/ # project card, modal, carousel
│ ├── controls/ # theme, language, music, colorblind toggles
│ ├── glass/ # reusable liquid-glass primitives
│ ├── motion/ # framer-motion wrappers (reveal, parallax)
│ └── NeuralBackground.tsx # animated WebGL/SVG backdrop
├── hooks/ # useTheme, useAudio, useMediaQuery, …
├── i18n/ # i18next setup + locales/ (en.json, es.json)
├── lib/ # framework-agnostic helpers (supabase, fetch)
├── styles/ # globals.css + liquid-glass utilities
└── utils/ # pure functions (slugify, sort, image helpers)
| You want to… | Edit… |
|---|---|
| Add a project or category | config/projects.json |
| Change colors, name, social links | .env |
| Add a new UI string | src/i18n/locales/en.json and es.json |
| Add a new section to the page | new file in src/components/sections/ + mount it in src/App.tsx |
| Add a new control (e.g. font-size) | new file in src/components/controls/ |
| Add a new theme or visual variant | extend tokens in tailwind.config.ts + src/styles/ |
| Add a new animation | wrap with helpers in src/components/motion/ |
| Add a new language | follow Section 3.8 |
| Wire a contact form | src/lib/ (Supabase helpers) + new section |
.env ──┐
├──► src/config/ ──► React components ──► rendered HTML
projects.json ──┘ │
├──► i18next (UI strings)
└──► Tailwind + CSS vars (theme)
src/config/env.tsparsesimport.meta.env.VITE_*into a typed object.src/config/projects.tsimportsconfig/projects.json, validates it, and exposes helpers likelocalizedProject(p, lang)andvisibleCategories(lang)so components never deal with raw bilingual fields.src/config/theme.tswrites CSS variables (--bg,--fg,--accent, …) from.envso Tailwind utilities resolve to the right color in every theme.
Say you want a "Talks" section:
- Create
src/components/sections/Talks.tsx. - Add UI strings (heading, empty state) to
src/i18n/locales/en.jsonandes.json. - Mount
<Talks />insrc/App.tsxbetween two existing sections. - (Optional) If the section is data-driven, add a new array to
config/projects.jsonand read it via a helper insrc/config/projects.ts.
No router, no lazy loading, no global state library. One page, one render tree.
Mimir is open source and contributions are welcome.
- Bugs and feature requests: open an issue with a clear repro and your OS / browser.
- Pull requests: fork, branch off
main, runpnpm exec tsc -b --noEmitbefore pushing. - Translations: adding a third language is the highest-leverage contribution. Follow Section 3.8 and open a PR.
- Themes: drop a
themes/<name>.envpreset and we'll add it to the docs. - Discussions: proposals for new sections, layouts or integrations go in GitHub Discussions.
By contributing you agree that your contribution is released under the project's MIT license.
MIT: fork it, make it yours. No attribution required, but a star on the repo is always appreciated.
