Aduok Code

A Designer and Coder Split Hero, Built from Two Mirrored SVG Portraits, Clip-Path Masks Driven by CSS Variables, and a Mouse-Following Ease Loop That Slowly Reveals One Side

Introduction

This hero is a single HTML file with no library and no photo. The word designer sits on the left, the word <coder> sits on the right, and a portrait stands between them that is painted with colourful brush strokes on one half and drawn in grey halftone with code on the other. Nothing is clicked. Move the mouse to the left and the painted designer slowly takes over the whole portrait while the coder text fades away. Move it to the right and the coder takes over. Let the mouse leave the page and the split eases back to the middle.

The page rests on four ideas: a portrait drawn once in SVG and reused in two styles, two full-size copies stacked on top of each other and cut at a moving line with clip-path, one number held in a CSS variable that decides the cut, the text and the portrait size at the same time, and a small loop that chases the mouse so the reveal is slow instead of instant.

What you will learn

How to draw a figure once and reuse it with use — how to mirror a shape with one transform — how to cut two stacked layers with clip-path and calc — how to drive layout from CSS variables set by JavaScript — how to ease toward a target with requestAnimationFrame — and how to keep the whole thing usable on a phone, with the keyboard, and with reduced motion.

The portrait is original

The face is a simple shape drawn in SVG, not a photo and not a copy of any existing artwork. To use your own picture, replace the contents of each layer with an img element and keep the same clip-path rules.

01
Part One
The Three-Column Hero

Step 1 — Two Text Columns and a Portrait in Between

The hero is one flex row with three children: the designer column, the portrait, and the coder column. The text columns start with a flex-basis of 0 and grow by a variable, so how much room each one takes is not written anywhere in the layout. It comes from a number the script sets. The left column is right-aligned and the right column is left-aligned, so both texts lean toward the face.

CSS
.hero {
  --s: 50;  --lv: 1;  --rv: 1;  --grow: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 16px;
  max-width: 1280px;
  margin: 0 auto;
}

.col {
  flex-basis: 0;
  flex-shrink: 1;
  min-width: 0;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  gap: 16px;
}
.col.left  { flex-grow: var(--lv); opacity: var(--lv); text-align: right; align-items: flex-end; }
.col.right { flex-grow: var(--rv); opacity: var(--rv); text-align: left;  align-items: flex-start; }

The headings use white-space: nowrap and a size of clamp(40px, 5.4vw, 96px). A column that shrinks to nothing needs overflow: hidden, otherwise the word would spill out over the portrait as its box collapses. The min-width: 0 is what allows a flex child that holds text to shrink below the width of its content.

Do not hard-code the layout of a state. Write the layout once and let a number move it.

02
Part Two
One Portrait, Drawn Once

Step 2 — A Hidden SVG That Holds the Shared Pieces

The portrait is built from a few pieces that both styles need: the outline of the body, neck and head, the hair and the face, and the brush strokes. They are written once inside a zero-size SVG at the top of the page, with an id on each group. Both visible portraits then pull them in with use href, so the face is guaranteed to be in the same place in both layers, to the pixel.

HTML
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
  <defs>
    <clipPath id="fig">
      <path d="M10 520 C20 455 100 425 160 410 L240 410 C300 425 380 455 390 520 Z"/>   <!-- shoulders -->
      <path d="M170 330 L230 330 L238 415 L162 415 Z"/>                                   <!-- neck -->
      <path d="M200 60 C275 60 305 120 300 200 C296 270 255 335 200 345 C145 335 104 270 100 200 C95 120 125 60 200 60 Z"/> <!-- head -->
    </clipPath>

    <pattern id="dots" width="6" height="6" patternUnits="userSpaceOnUse">
      <circle cx="3" cy="3" r="1.3" fill="#141B26" opacity="0.28"/>
    </pattern>

    <g id="strokes"> ... rounded rects, rotated, in blue, orange, mint and pink ... </g>
    <g id="face">    ... hair, eyebrows, eyes, nose and mouth ... </g>
  </defs>
</svg>

The clipPath is the silhouette. Anything drawn inside a group that uses clip-path="url(#fig)" is trimmed to the body, so the brush strokes appear to be painted on the person instead of on the page. The dots pattern is a tiny repeating circle, and filling the head with it gives the grey halftone look of the coder side without any image.

The strokes are plain rounded rectangles with a rotation, which reads as a paint swipe. A few thin white ones on top suggest a bristle highlight. The face sits at the centre of a 400-unit drawing, at x = 200, and that matters for the next step.

03
Part Three
Two Layers, One Cut Line

Step 3 — Two Full Portraits Stacked and Cut with clip-path

The stage is a box with a fixed aspect ratio of 580 by 520. Inside it are two SVGs that each cover the whole box. Layer a is the painted designer. Layer b is the grey coder. Neither one is half a face. Each is a complete portrait, and a clip-path hides the part you should not see. That is what lets one side grow to the full width of the portrait: its drawing was always complete underneath.

CSS
.stage { position: relative; width: 100%; aspect-ratio: 580 / 520; }

.layer {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}

/* --s is where the cut sits, from 0 to 100 */
.layer.a { clip-path: inset(0 calc(100% - var(--s) * 1%) 0 0); }   /* shows the left  s%       */
.layer.b { clip-path: inset(0 0 0 calc(var(--s) * 1%)); }          /* shows the right 100-s%   */

At the default of 50 the two layers meet exactly in the middle, so the portrait looks like one face split in two. At 100 the painted layer shows everything and the grey layer shows nothing. At 0 it is the other way round. Because both clip-paths read the same variable, the two edges always touch, and there is never a gap or an overlap between them.

The painted layer has a problem. In the first design only the left half of the face was painted, so there were no strokes on the right side. To show the painted face in full, the strokes are drawn a second time, mirrored. The mirror is one transform: translate(400 0) scale(-1 1) flips x around the line x = 200, which is exactly the centre of the face, so the copy lands on the opposite side as a perfect reflection.

HTML
<svg class="layer a" viewBox="-90 0 580 520" aria-hidden="true" focusable="false">
  <use href="#spill"/>
  <use href="#spill" transform="translate(400 0) scale(-1 1)"/>

  <g clip-path="url(#fig)">
    <rect x="-90" y="0" width="580" height="520" fill="#F1E8DA"/>
    <use href="#strokes"/>
    <use href="#strokes" transform="translate(400 0) scale(-1 1)"/>
  </g>

  <use href="#face"/>
</svg>

The coder layer does the same job with different paint. It fills the body and head with a warm grey, lays the dots pattern over the head, writes a few lines of code in a monospace font across the chest, and adds floating CSS and HTML fragments in the corners at low opacity. The shared face group is placed on top last, so the eyes and the mouth are identical on both sides.

Why not just clip one image?

A single image cut in half can only ever show its own half. Two complete drawings stacked with a mask let either side expand to fill the frame, and the mask does all the work, so there is nothing to redraw while the split moves.

04
Part Four
One Number Drives Everything

Step 4 — Four CSS Variables Set by One Function

The state of the whole hero is one number, s, between 0 and 100. The script turns it into four variables on the hero element, and the stylesheet does the rest. The cut line reads --s. The designer text reads --lv, which is 1 until the split moves past the middle and then falls to 0. The coder text reads --rv, which does the same from the other side. The portrait reads --grow, which is 0 in the middle and 1 at either end.

JavaScript
function paint() {
  var s = current;
  hero.style.setProperty('--s',    s.toFixed(2));
  hero.style.setProperty('--lv',   clamp(s / 50, 0, 1).toFixed(3));
  hero.style.setProperty('--rv',   clamp((100 - s) / 50, 0, 1).toFixed(3));
  hero.style.setProperty('--grow', (Math.abs(s - 50) / 50).toFixed(3));
}

The portrait grows with calc, using --grow to blend between a small size and a large one. The two sizes are clamp values themselves, so the portrait scales with the screen at both ends. Since the text columns shrink as the portrait grows, the row always adds up, and no number has to be written for the in-between sizes.

CSS
.portrait {
  flex-grow: 0;
  flex-shrink: 1;
  flex-basis: calc(
    clamp(320px, 38vw, 520px) +
    (clamp(360px, 52vw, 720px) - clamp(320px, 38vw, 520px)) * var(--grow)
  );
  min-width: 0;
}

There is no transition on any of these properties. Smoothness comes from the script, which only ever moves s by a small step per frame, so every property that reads it moves smoothly at the same speed. A CSS transition on top would fight with it and make the text lag behind the portrait.

05
Part Five
Following the Mouse, Slowly

Step 5 — A Target, a Current Value, and an Ease Loop

The mouse does not set s. It sets a target. A separate value, current, chases the target a few percent of the distance at a time, which is a simple ease-out. Move the mouse quickly and the reveal still glides. Stop and it settles with no overshoot. The loop only runs while there is something left to move, and switches itself off when the gap is smaller than 0.02, so an idle page uses no CPU.

JavaScript
var target  = 50;     // where the split wants to be
var current = 50;     // where it is now
var EASE    = reduce ? 1 : 0.06;   // lower is slower
var running = false;

function tick() {
  var diff = target - current;
  if (Math.abs(diff) < 0.02) {
    current = target;  paint();  running = false;  return;
  }
  current += diff * EASE;
  paint();
  requestAnimationFrame(tick);
}

function setTarget(v) {
  target = clamp(v, 0, 100);
  if (!running) { running = true; requestAnimationFrame(tick); }
}

The mouse position is measured against the width of the whole window, not the hero, so the reveal begins as soon as the pointer moves and does not wait for it to touch the portrait. The x position is turned into a value from -1 at the left edge to 1 at the right edge. It is multiplied by 1.15 and clamped, so the extreme is reached a little before the edge and one side can be shown completely without pushing the pointer into the scrollbar.

JavaScript
function fromX(x) {
  var t = (x / window.innerWidth - 0.5) * 2;       // -1 at the left edge, 1 at the right edge
  setTarget(50 - 50 * clamp(t * 1.15, -1, 1));     // mouse left = more designer
}

window.addEventListener('pointermove', function (e) { fromX(e.clientX); });
window.addEventListener('pointerdown', function (e) { fromX(e.clientX); });
document.documentElement.addEventListener('pointerleave', function () { setTarget(50); });

Pointer events cover the mouse, a pen, and a finger with one set of listeners. A tap on a phone moves the split to that side, and the page sets touch-action: pan-y so vertical scrolling still works. When the pointer leaves the document, the target goes back to 50 and the portrait eases back to the middle.

06
Part Six
The Reversed Side Bug

Step 6 — Why the Sign Is a Minus

The first version of the mouse code used a plus: setTarget(50 + 50 * t). It looked right and was wrong. The painted layer shows the left s percent of the portrait, so a larger s means more designer. With a plus, moving the mouse to the left made s smaller, which shrank the designer and revealed the coder. The hover worked on the opposite side from the one you were pointing at.

JavaScript
// wrong: left edge gives t = -1, so s = 0, which is all coder
setTarget(50 + 50 * clamp(t * 1.15, -1, 1));

// right: left edge gives t = -1, so s = 100, which is all designer
setTarget(50 - 50 * clamp(t * 1.15, -1, 1));
Check what the variable means before you wire an input to it

The variable here is how much of the left layer is visible, not which side the mouse is on. Those are two different things, and a single sign decides whether they agree. The text columns need no change because they already read --lv and --rv from the same number.

07
Part Seven
Phones, Keys, and Reduced Motion

Step 7 — Stacking, the Keyboard, and Reduced Motion

Below 900 pixels the row turns into a column. The portrait moves to the top with order: -1, the designer text is left-aligned like the coder text, and the heading size is based on the viewport width so a long word still fits. The reveal still works through taps, but the text columns stack under the portrait and no longer need to share a row with it.

CSS
@media (max-width: 900px) {
  .hero { flex-direction: column; align-items: stretch; gap: 16px; }
  .portrait { order: -1; flex: 0 0 auto; width: 100%; max-width: 560px; margin: 0 auto; }
  .col.left { text-align: left; align-items: flex-start; }
  h2 { font-size: clamp(48px, 14vw, 96px); }
}

The keyboard can drive the same target. The left arrow moves the split toward the designer by 12 points, the right arrow toward the coder, and Home or Escape returns to the middle. With reduced motion on, EASE becomes 1, so the loop reaches the target in a single frame. The reveal still works, it just does not glide.

JavaScript
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

window.addEventListener('keydown', function (e) {
  if (e.key === 'ArrowLeft')  { setTarget(target + 12); }
  if (e.key === 'ArrowRight') { setTarget(target - 12); }
  if (e.key === 'Home' || e.key === 'Escape') { setTarget(50); }
});

The stage has role="img" and a label that explains both halves and what the mouse does, and the two SVG layers inside it are hidden from screen readers. That way the portrait is announced once as a single picture instead of as a pile of shapes.

Tuning Reference

ValueExampleEffect
EASE0.06Share of the remaining distance covered each frame; lower is slower and smoother
Mouse range multipliert * 1.15Above 1 reaches a full reveal before the window edge
Stage aspect ratio580 / 520Must match the viewBox of both SVG layers
Mirror transformtranslate(400 0) scale(-1 1)Flips a shape around x = 200, the centre of the face
Portrait size, restclamp(320px, 38vw, 520px)Width of the portrait when the split is in the middle
Portrait size, fullclamp(360px, 52vw, 720px)Width of the portrait when one side is fully shown
Stop threshold0.02Gap below which the loop stops and the page goes idle
Keyboard step12Points the split moves per arrow press
Stack breakpoint900pxWidth below which the hero becomes one column

Full Source Code

The whole project is a single self-contained HTML file with its markup, styles, and script together and no build step. The only external request is the Google Fonts stylesheet for Outfit and JetBrains Mono, and the page falls back to system fonts if it does not load. It needs a browser that supports clip-path on SVG elements, CSS variables in calc, and pointer events, which means any current Chrome, Edge, Safari, or Firefox.

HTML — designer-coder-hero.html (complete)
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/designer-coder-hero.html
Want to try it yourself?Open the live designer-coder-hero.html file