This post walks you through building a portfolio site with a 3D interactive homepage (matryoshka dolls driven by scroll) using Hugo and Three.js, and then deploying it on Cloudflare Pages. No prior 3D experience required—we keep the Three.js part to the essentials.


What You’ll Build

  • A Hugo static site with the PaperMod theme.
  • A custom homepage where a 3D model (e.g. matryoshka dolls) reacts to scroll: each “doll” opens as you scroll, and you can click to navigate to Blog, CV, Links, etc.
  • Deployment on Cloudflare Pages so every push to your repo updates the live site.

1. Project setup: Hugo + PaperMod

Create a new Hugo site and add the theme as a submodule (so the theme is tracked by Git but lives in its own repo):

hugo new site my-portfolio
cd my-portfolio
git init
git submodule add --depth=1 https://github.com/adityatelange/hugo-PaperMod.git themes/PaperMod

In config.toml, set the theme and base URL:

baseURL = "http://localhost:1313/"
title = "My Portfolio"
theme = "PaperMod"

Run the dev server to see the default theme:

hugo server -D

2. Three.js basics (just what we need)

Three.js is a JavaScript library for 3D in the browser. You only need a few concepts:

Concept What it does
Scene The 3D world that holds all objects.
Camera The “eye” that looks at the scene (we use a perspective camera).
Renderer Draws the scene into a <canvas> on the page.
Mesh A 3D shape (geometry + material). Our model is made of meshes.
GLB/GLTF A standard 3D file format. We load one GLB and reuse it.

Rough flow:

  1. Create scene, camera, renderer; append the renderer’s canvas to a div.
  2. Load a GLB with GLTFLoader; add the loaded model to the scene.
  3. Each frame, update positions/visibility from scroll (or time) and call renderer.render(scene, camera).

THREE.Scene(), THREE.PerspectiveCamera(), THREE.WebGLRenderer(), and THREE.GLTFLoader().


model.js workflow (simplified)

In practice, model.js follows this structure: setup scene and renderer, load the GLB once, clone it per menu item, then every frame derive scroll progress and update which doll is “open” and where labels/click areas are.

// 1. Setup
const container = document.getElementById('model-container');
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
container.appendChild(renderer.domElement);

// 2. Load one GLB, then clone it for each menu item
const loader = new THREE.GLTFLoader();
let models = [];
loader.load('/models/one_matrioska.glb', function (gltf) {
  const NUM_MODELS = 4; // e.g. from data-menu-count
  for (let i = 0; i < NUM_MODELS; i++) {
    const model = gltf.scene.clone();
    model.scale.setScalar(Math.pow(0.82, i));
    scene.add(model);
    models.push(model);
    // ... pick top_part / bottom_part by name, apply texture
  }
  // 3. Add lights, position camera, start loop
  animate();
});

// 4. Every frame: scroll → which doll is open → update positions & labels & click boxes
function animate() {
  requestAnimationFrame(animate);
  const scrollProgress = window.scrollY / (document.documentElement.scrollHeight - window.innerHeight);
  const modelIndex = Math.floor(scrollProgress * NUM_MODELS);
  const modelProgress = (scrollProgress * NUM_MODELS) % 1;
  updateModelVisibility(modelIndex, modelProgress); // move lids, show/hide, update labels
  renderer.render(scene, camera);
}

So: init → load GLB → clone & scale per menu item → animate loop (scroll → visibility/labels → render).


3. How the model is used in this project

The homepage uses one GLB file that contains a single matryoshka made of two named parts:

  • top_part – the lid/head
  • bottom_part – the body

The script then:

  1. Clones that model once per menu item (e.g. 4 dolls for Home, Blog, CV, Links).
  2. Scales each clone (e.g. 0.82^0, 0.82^1, 0.82^2 …) so they nest visually.
  3. Scroll is mapped to a 0–1 progress. Progress is split into segments (one per doll). For each segment:
    • The current doll’s top_part moves up (lid opens).
    • The bottom_part of the current doll stays visible; when the lid is open enough, the next doll’s bottom becomes visible.
  4. Labels (menu names) and clickable areas are updated every frame from the 3D positions of the visible parts, projected to screen space.

So: one GLB, cloned and scaled; scroll drives “which doll is open” and “how much”; clicks are invisible divs over the visible top/bottom parts.


4. Key files in the repo

What Where
Homepage layout layouts/index.html – overrides the theme’s home. Defines the full‑viewport div for the 3D view, passes menu count/labels/subtitles via data attributes, and includes the Three.js scripts.
3D logic static/js/model.js – creates the scene, loads the GLB and texture, clones models, wires scroll to updateModelVisibility(), and runs the animation loop.
Three.js + loader static/js/three.min.js, static/js/GLTFLoader.js – can be copied from the Three.js repo or the GLTFLoader example (or use a build step). Keeping them under static/ avoids external CDN dependency.
Model + texture static/models/one_matrioska.glb, static/models/Matryoshka_doll_baseColor.png – the GLB and its texture.
Menu config config.toml[menu] entries; each can have matryoshkaTextLabel and matryoshkaSubtitle for the labels shown on the homepage.

The homepage layout injects menu data into the page so the script doesn’t hardcode links:

  • data-menu-count – number of menu items (= number of dolls).
  • data-matryoshka-labels – comma‑separated labels.
  • data-matryoshka-subtitles – comma‑separated subtitles.
  • A <script type="application/json" id="menu-urls-data"> with the menu URLs as JSON.

model.js reads these and creates one “doll” per menu item, updates labels, and opens the correct URL on click.


5. Minimal custom homepage layout

Your layouts/index.html can look like this (Hugo template + one container + scripts):

{{ define "main" }}
    {{- if (and site.Params.profileMode.enabled .IsHome) }}
        {{- partial "index_profile.html" . }}
    {{- else }}
        <div id="model-container"
             style="width: 100%; height: 100vh; position: fixed; top: 0; left: 0; z-index: -1;"
             data-menu-count="{{ len site.Menus.main }}"
             data-matryoshka-labels="..."
             data-matryoshka-subtitles="...">
        </div>
        <script type="application/json" id="menu-urls-data">...</script>
        <script src="/js/three.min.js"></script>
        <script src="/js/GLTFLoader.js"></script>
        <script src="/js/model.js"></script>
        <div style="height: 600vh;"></div>
    {{- end }}
{{ end }}

The tall div at the end gives enough scroll height so the scroll-based animation has room. The real implementation in the repo builds the label/subtitle/URL data from site.Menus.main so the 3D view stays in sync with your menu.


6. Deploy on Cloudflare Pages

Once the site works locally (hugo server and the 3D homepage loads):

  1. Push your repo to GitHub (or GitLab).
    Ensure the theme is committed as a submodule and that git submodule update --init --recursive works (Cloudflare will run this when it clones the repo).

  2. Cloudflare DashboardWorkers & PagesCreatePagesConnect to Git.
    Select the repository and branch (e.g. main or master).

  3. Build settings:

    • Framework preset: None (or “Hugo” if Cloudflare offers it and your Hugo version is supported).
    • Build command:
      hugo --environment production
    • Build output directory: public
  4. Production config
    In config/production/config.toml (or your production config file), use relative URLs so the same build works on any domain:

    baseURL = "/"
    relativeURLs = true
    canonifyURLs = false
    

    This way the site works on *.pages.dev and on your custom domain without changing the build.

  5. Save and deploy
    Cloudflare will clone the repo, run hugo --environment production, and serve the contents of public/. After the first build, add a custom domain under the project’s Custom domains if you want.


Summary

  • Hugo gives you a fast static site and the PaperMod theme; the custom homepage is a single layouts/index.html that renders a full-screen div and injects menu data.
  • Three.js is used to load one GLB matryoshka, clone it per menu item, and drive visibility/position from scroll; clicks are handled with screen-space divs over the visible top/bottom parts.
  • Cloudflare Pages builds the site with hugo --environment production and serves the public/ folder; with relative URLs, one build works on the default *.pages.dev URL and on your custom domain.

If you want to go deeper, the next steps are: tweaking the scroll-to-doll mapping in model.js, changing the model or texture in static/models/, and adjusting the menu and labels in config.toml.