Try the joystick here

This joystick looks like a tiny 3D object, but it is built from flat HTML layers. The depth comes from gradients, inset shadows, outer shadows, SVG arrows, and pointer-driven CSS variables.

Drag the center knob or press the arrows. This is the actual component, not a video preview.

What you are building

You are building a draggable analog joystick UI. It looks raised, recessed, and touchable, but the whole visual is made from flat circles.

There is no Canvas, no WebGL, and no image texture. The component is made from five visual layers: outer ring, dark socket, inner base, mushroom head, and top details.

Open the demo + code page if you want a larger preview with the complete standalone HTML underneath.

Copy the structure

The structure is intentionally simple. Four real buttons sit around the joystick, and the center is a nested stack of circles.

HTML
<button class="control top" aria-label="Move up">...</button>
<button class="control right" aria-label="Move right">...</button>
<button class="control bottom" aria-label="Move down">...</button>
<button class="control left" aria-label="Move left">...</button>

<div class="around">
  <div class="handle">
    <div class="button-wrapper">
      <span class="inside">
        <span class="dot"></span>
        <span class="dot"></span>
        <span class="dot"></span>
        <span class="dot"></span>
      </span>
    </div>
  </div>
</div>

The four button.control elements should stay as actual buttons. That makes the directional controls easier to wire up and better for accessibility.

around::before adds one extra visual circle without adding another HTML element. That pseudo-element becomes the dark recessed socket.

Build the fake depth

The depth is not one trick. It is a small system of highlights and shadows.

  • .around uses a top-to-bottom gradient to create the outer bevel.
  • .around::before darkens the center so it reads as a socket.
  • .handle gives the inner base a bright top and a darker lower edge.
  • .button-wrapper carries the mushroom head and the strongest floating shadow.
  • .inside adds the top face, inset highlight, and small dot details.
Core CSS Depth
.around {
  background-image: linear-gradient(0deg, #f5f8fa, #9da4a8);
}

.button-wrapper {
  background-image: linear-gradient(180deg, #adb9bf, #d4dbdd);
  box-shadow:
    0 -12px 10px rgba(255, 255, 255, 0.5),
    0 9px 14px rgba(0, 0, 0, 0.5),
    inset 0 10px 13px rgba(255, 255, 255, 0.72),
    inset 0 -14px 18px rgba(86, 101, 108, 0.44);
}

The important idea: highlights should sit on the top side, and heavier shadows should sit below the object. Once that direction is consistent, the flat circles start to feel physical.

The mushroom head needs a tight lower shadow; without it, the joystick looks pasted on instead of raised. The top face needs inset highlights and a soft lower inset shadow to sell the thickness.

Add arrows and details

Once the depth works, add the directional arrows and four small dots. These are small details, but they make the control feel intentional instead of decorative.

Use SVG for the arrows so they stay sharp at any size. Keep each arrow inside a button and update its active color based on direction.

  • Idle arrows: neutral gray.
  • Pressed direction: warm active color.
  • Diagonal drag: two arrows can be active at different intensities.
  • Dots: subtle, low-contrast, and slightly inset.

Direction states should respond to the actual joystick vector, not just hard-coded button clicks.

Make it draggable

JavaScript does not need to draw the joystick. It only needs to read pointer position, convert it into an x and y vector, clamp that vector inside a circle, then write CSS variables.

Movement CSS
.button-wrapper {
  transform: translate(var(--joy-x), var(--joy-y)) scale(var(--joy-scale));
}

.handle {
  background:
    radial-gradient(circle at calc(50% + var(--well-light-x)) calc(42% + var(--well-light-y)), ...),
    radial-gradient(circle at calc(50% + var(--well-dark-x)) calc(58% + var(--well-dark-y)), ...),
    linear-gradient(...);
}
Direction Vector
setJoystick(x, y);

// x and y should stay between -1 and 1.
// Clamp the drag distance to a circular range before writing CSS variables.

Use the vector to decide how strong each arrow should look:

  • Up: Math.max(0, -y)
  • Right: Math.max(0, x)
  • Down: Math.max(0, y)
  • Left: Math.max(0, -x)

When the knob moves, update the socket lighting and the knob shadow together. That is what makes the drag feel believable.

What to tweak

After the demo works, tweak these parts first:

  • Size: change the wrapper dimensions and keep all inner circles proportional.
  • Depth: adjust shadow offset and blur before changing colors.
  • Movement: reduce the drag radius if the knob feels too loose.
  • Active color: change the arrow color to match your UI theme.
  • Touch feel: use transform, not left or top, so the motion stays smooth.
  • Accessibility: keep the directional controls as real buttons with labels.

Common mistakes: making every shadow too soft, mixing light directions, using a screenshot as a texture, or turning the arrows into decoration instead of controls.

Credits

Thanks to the original visual references behind this recreation.

Links