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.
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.
<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.
.arounduses a top-to-bottom gradient to create the outer bevel..around::beforedarkens the center so it reads as a socket..handlegives the inner base a bright top and a darker lower edge..button-wrappercarries the mushroom head and the strongest floating shadow..insideadds the top face, inset highlight, and small dot details.
.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.
.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(...);
}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, notleftortop, 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.
- Button visual design reference: Pinterest source
- Joystick breakdown reference: original Douyin video