Introduction
This login page has no image files, no animation library, and no icon pack. There is a table lamp on a dark page with a thin string hanging from its shade, and a small brass bead on the end of the string. Drag the bead down and the lamp clicks on: a warm glow spreads across the background and a frosted glass login card fades in beside it. Pull again and the light goes out and the card disappears.
The effect rests on four small ideas: a lamp drawn as one inline SVG, a single data attribute on the page that every lit style hangs from, a pointer handler that moves the bead and fires the switch when it passes a threshold, and a small spring loop that pulls the cord back to rest. One short script wires them together and also makes the lamp work with a tap, a finger, and the keyboard.
How to draw an object in SVG so its glow can sit behind it — how to drive a whole page from one data attribute — how to convert pointer positions into SVG coordinates so dragging works at any size — how to write a damped spring in a dozen lines — how to hide a form with inert so it leaves the tab order — and how to make a drag-only control usable without a mouse.
Step 1 — Four Shapes and a String
The lamp is one SVG with a 240 by 340 viewBox. It is made of a dome-shaped shade, a thin pole, a rounded base, and a faint cone of light that sits behind everything. The cone is drawn first so the pole and shade paint over it. All coordinates are plain numbers in that viewBox, so the lamp scales to any width without a single media query inside the drawing.
<svg id="lamp-svg" viewBox="0 0 240 340">
<path class="cone" d="M30 116 L210 116 L300 340 L-60 340 Z" fill="url(#cone-fade)" />
<rect class="pole" x="114" y="112" width="12" height="192" />
<rect class="pole" x="68" y="300" width="104" height="13" rx="6.5" />
<path class="shade" d="M20 112 C20 60 60 28 120 28 C180 28 220 60 220 112 Z" />
<line id="cord" class="cord" x1="162" y1="112" x2="162" y2="206" />
<g id="pull" role="switch" tabindex="0" aria-checked="false">
<g id="bead" transform="translate(162 206)">
<circle r="22" fill="transparent" />
<circle class="ring" r="12" />
<circle class="bead" r="6" />
</g>
</g>
</svg>The shade is a single path: two curves rising from the left and right edges to a dome, closed by a flat bottom at y 112. The cord is a line that starts under the shade at x 162 and ends at the bead. Only its second end moves, so the string stays attached to the lamp while the bead travels.
The bead group holds three circles. The visible bead has a radius of 6, which is too small to grab with a finger, so a transparent circle with a radius of 22 sits on top of it as the hit target. A third circle, the ring, is invisible until the group has keyboard focus.
The light cone extends past the edge of the viewBox, and the glow later in the page is larger than the lamp itself. The SVG is set to overflow: visible so the cone is not clipped at the drawing boundary. The page clips the overflow once, on the outer scene element, so nothing causes a horizontal scrollbar.
Step 2 — data-lit Drives Every Lit Style
The lamp has two states, and the script stores them in one place: a data-lit attribute on the scene that is either false or true. Nothing else in the script touches colours or opacity. The CSS reads the attribute and changes the shade, the pole, the cone, the glow, and the login card together.
.shade { fill: var(--shade-off); transition: fill 0.4s var(--ease), filter 0.5s var(--ease); }
.pole { fill: var(--pole-off); transition: fill 0.4s var(--ease); }
.cone { opacity: 0; transition: opacity 0.6s var(--ease); }
.scene[data-lit="true"] .glow { opacity: 1; }
.scene[data-lit="true"] .cone { opacity: 1; }
.scene[data-lit="true"] .shade {
fill: var(--shade-on);
filter: drop-shadow(0 0 16px rgb(var(--warm) / 0.75));
}
.scene[data-lit="true"] .pole { fill: var(--pole-on); }The shade changes from a dim cream to a bright warm white and gains a drop shadow in the warm colour, which reads as the bulb glowing through the cloth. The pole warms slightly too. Because every change is a CSS transition, the lamp fades on over about half a second instead of snapping.
.glow {
position: absolute;
left: 50%;
top: 20%;
z-index: -1;
width: 200vmax;
height: 200vmax;
transform: translate(-50%, -50%);
background: radial-gradient(
closest-side,
rgb(var(--warm) / 0.24),
rgb(var(--warm) / 0.08) 32%,
transparent 68%
);
opacity: 0;
transition: opacity 0.7s var(--ease);
pointer-events: none;
}The glow is one oversized square with a radial gradient. It is centred on the shade at 20 percent of the lamp height, and it is 200vmax wide, so even on a very large screen the gradient fades out before it reaches an edge. The gradient uses closest-side, which makes the circle fit inside the square so the fade finishes at the middle of each side. The scene uses isolation: isolate and the glow has z-index -1, which places it behind the lamp and the card without hiding it behind the page background.
One state, written in one place. The script says what the lamp is. The CSS says what that looks like.
Step 3 — Pointer Events in SVG Coordinates
A pointer reports its position in screen pixels, but the cord lives in the SVG viewBox, and the lamp can be drawn at any size. The two need converting. The SVG gives you the matrix that maps its own coordinates to the screen, and inverting that matrix maps a pointer position back into the drawing.
function toSvg(e) {
const pt = svg.createSVGPoint();
pt.x = e.clientX;
pt.y = e.clientY;
return pt.matrixTransform(svg.getScreenCTM().inverse());
}
function render() {
const x = REST.x + offset.x, y = REST.y + offset.y;
cord.setAttribute("x2", x);
cord.setAttribute("y2", y);
bead.setAttribute("transform", `translate(${x} ${y})`);
}The render function is the only place the cord is drawn. It takes the resting position of the bead, adds the current offset, and writes the result to the line end and the bead group. Everything that moves the cord, whether a drag or a spring, only changes the offset and calls render.
pull.addEventListener("pointerdown", (e) => {
e.preventDefault();
pull.setPointerCapture(e.pointerId);
cancelAnimationFrame(raf);
const p = toSvg(e);
grab = { x: p.x - (REST.x + offset.x), y: p.y - (REST.y + offset.y) };
dragging = true;
fired = false;
moved = 0;
});
pull.addEventListener("pointermove", (e) => {
if (!dragging) return;
const p = toSvg(e);
offset.y = clamp(p.y - grab.y - REST.y, 0, MAX_PULL);
offset.x = clamp((p.x - grab.x - REST.x) * 0.35, -10, 10);
if (!fired && offset.y >= TRIGGER) {
fired = true;
setLit(!lit);
}
render();
});On pointer down the script stores the grab offset, the gap between the pointer and the centre of the bead, so the bead does not jump to the pointer when you pick it up. setPointerCapture routes all later move and up events to the bead even when the pointer leaves it, which is what lets you drag fast and wide without losing the string. The pull handle also has touch-action: none in the CSS so a finger drag moves the cord instead of scrolling the page.
During the drag, the vertical offset is clamped between 0 and 54 units, so the string can only be pulled down and only so far. The horizontal offset follows the pointer at about a third of its distance, limited to 10 units either way, which lets the bead lean a little to the side the way a real string does. The switch fires once, at the moment the pull passes 28 units. The fired flag stops it from toggling again during the same drag, so wiggling the bead around the threshold cannot flicker the light.
Step 4 — A Damped Spring in a Dozen Lines
When you let go, the cord does not slide back at a constant speed. It is pulled to rest by a spring: the further it is from rest, the harder it is pulled, and a damping term slows it so it overshoots a little and settles. The loop runs once per frame on requestAnimationFrame and stops itself when nothing is moving.
const SPRING = { k: 240, c: 14 };
function tick(now) {
const dt = Math.min((now - lastTick) / 1000, 0.032);
lastTick = now;
offset.vx += (-SPRING.k * offset.x - SPRING.c * offset.vx) * dt;
offset.vy += (-SPRING.k * offset.y - SPRING.c * offset.vy) * dt;
offset.x += offset.vx * dt;
offset.y += offset.vy * dt;
render();
const energy = Math.abs(offset.x) + Math.abs(offset.y)
+ Math.abs(offset.vx) + Math.abs(offset.vy);
if (energy < 0.05) {
offset.x = offset.y = offset.vx = offset.vy = 0;
render();
raf = 0;
return;
}
raf = requestAnimationFrame(tick);
}Each frame adds an acceleration to the velocity and the velocity to the position. The acceleration is minus k times the offset, which is the spring pulling toward zero, minus c times the velocity, which is the damping. With k at 240 and c at 14 the spring is under-damped, so the bead swings back past rest once or twice and then stops. The time step is capped at 32 milliseconds so a tab that was in the background does not send the numbers flying when it wakes up. When the combined offset and speed drop below 0.05 the script snaps everything to exactly zero and stops asking for frames.
The same loop handles taps. A tap is a pointer up with almost no travel, under 4 pixels, and no switch fired during the press. In that case the script toggles the lamp directly and gives the bead a downward velocity of 520 units per second from rest. The spring turns that kick into a visible tug of roughly 19 units, which stays under the 28-unit trigger on purpose: the tap already decided the state, and the tug is only feedback.
Whichever way the lamp is operated, the result goes through setLit. A drag calls it when the threshold is crossed, a tap calls it on release, and the Enter and Space keys call it from the keydown handler. The click sound, the attribute change, and the hint text are all in that one function.
Step 5 — Hidden Means Hidden, Including the Tab Key
The login card has to disappear when the light is off, and it has to stay out of the way of a keyboard or a screen reader while it is gone. Fading it with opacity alone would leave the inputs focusable. The card therefore uses three things together: opacity and transform for the fade, visibility for the hidden state, and the inert attribute for the behaviour.
.card {
opacity: 0;
visibility: hidden;
transform: translateY(14px) scale(0.98);
transition: opacity 0.5s var(--ease),
transform 0.5s var(--ease),
visibility 0s linear 0.5s;
}
.scene[data-lit="true"] .card {
opacity: 1;
visibility: visible;
transform: none;
transition-delay: 0.12s, 0.12s, 0s;
}Visibility cannot be faded, so it needs a trick. When the light goes off, the visibility change waits half a second, the length of the fade, and then flips, so the card fades out before it is removed. When the light comes on, the visibility delay is zero, so the card is visible immediately and the opacity and transform start 0.12 seconds later, which makes the card appear just after the glow begins instead of at the same instant.
function setLit(next) {
lit = next;
scene.dataset.lit = String(lit);
pull.setAttribute("aria-checked", String(lit));
card.inert = !lit;
hint.textContent = lit ? "Pull again to switch off"
: "Pull the string to switch on the light";
if (!lit && card.contains(document.activeElement)) pull.focus();
click();
}Setting inert on the card removes everything inside it from the tab order and from assistive technology, and ignores clicks on it. The card starts with the inert attribute in the HTML, so it is already unreachable before the script runs. If the light is switched off while focus is inside the card, which is possible from the keyboard, the script moves focus back to the lamp switch so it is not lost.
The bead group has role="switch", tabindex="0", and an aria-checked value that setLit keeps in sync. Enter and Space toggle it, and a ring appears around the bead when it has keyboard focus. The lamp is operable by anyone, even though dragging is the main interaction.
Step 6 — Glass, Gold, and Honest Validation
The card is a frosted glass panel: a translucent white fill, a one-pixel light border, a backdrop blur of 18 pixels, and a soft shadow with a faint highlight along the top edge. The blur only shows because the glow sits behind the card, which is why the card looks like glass in the lit state and nothing in the dark one. The Sign In button is a diagonal gradient that alternates between dark and light gold, with a pale line along its top edge to catch the light.
:root {
--bg: #0e0f12;
--fg: #f3efe4;
--muted: #9b9a94;
--warm: 255 206 124; /* space-separated so alpha can be added */
--shade-off: #d8d3c3;
--shade-on: #fff6dc;
--font-display: "Outfit", system-ui, sans-serif;
--font-body: "Figtree", system-ui, sans-serif;
color-scheme: dark;
}
/* any warm tint, any opacity */
box-shadow: 0 0 0 3px rgb(var(--warm) / 0.18);The warm colour is stored as three space-separated numbers instead of a hex value. That lets one token feed the glow, the shade shadow, the input focus ring, and the button shadow at different opacities with the rgb() slash syntax. The page is dark by design, so it sets its colours explicitly and declares color-scheme: dark so the inputs and scrollbars match.
form.addEventListener("submit", (e) => {
e.preventDefault();
const name = user.value.trim();
setError(user, userErr, name ? "" : "Enter your username.");
setError(pass, passErr, pass.value ? "" : "Enter your password.");
status.textContent = "";
if (!name) return user.focus();
if (!pass.value) return pass.focus();
status.textContent = "Signed in as " + name + ".";
});The form has novalidate so the page controls the messages. Each field has an error paragraph linked by aria-describedby, and aria-invalid flips to true when the message appears and back to false as soon as the user types. A failed submit moves focus to the first empty field. The status line is a polite live region, so a screen reader announces the result without the page stealing focus.
The submit handler shows a message and nothing more. To make it real, send the values to your server with fetch over HTTPS, check them there, and let the server decide whether the user is signed in. Client-side checks only help the user fix mistakes; they never protect anything.
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
}
}For anyone who asks their system for reduced motion, the CSS transitions become near-instant, and the script skips the spring and puts the cord straight back at rest. The lamp still switches, the glow still appears, and the card still shows and hides. Only the movement between those states is removed.
Tuning Reference
| Property | Example | Effect |
|---|---|---|
| REST | { x: 162, y: 206 } | Where the bead hangs; the cord starts at y 112 under the shade |
| MAX_PULL | 54 | Furthest the bead can be dragged down, in SVG units |
| TRIGGER | 28 | Pull distance at which the light clicks on or off during a drag |
| TAP_SLOP | 4 | Pointer travel in pixels that still counts as a tap |
| SPRING.k | 240 | Stiffness; higher snaps back faster |
| SPRING.c | 14 | Damping; lower gives more bounce, around 31 is none |
| tug velocity | 520 | Downward kick on a tap, which peaks near 19 units |
| --warm | 255 206 124 | Colour of the glow, the shade, and every focus ring |
| .glow size | 200vmax | How far the light reaches before it fades out |
| card fade | 0.5s, delay 0.12s | How long the card takes to appear after the glow starts |
Full Source Code
The whole project is a single self-contained HTML file — markup, styles, and script, with no build step. The only external request is the Google Fonts stylesheet for the two typefaces.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/lamp-login.html