Introduction
In this article we build BorderLogin — a hover-reveal login form with an animated dual-layer spinning border made entirely from CSS conic gradients. The card sits collapsed at 400×200px showing only the spinning border effect. On hover it expands to 450×500px and the full login form slides up into view. No JavaScript, no frameworks, no external UI libraries — just HTML, CSS custom properties, and the modern @property at-rule.
The spinning border is produced by two stacked pseudo-elements — ::before and ::after — each carrying a repeating-conic-gradient that rotates via an animated @property custom angle variable. The pink layer and the cyan layer are offset by one second, creating a woven two-color light effect. The entire component is themeable by editing a single :root block.
How to animate a CSS custom property with @property and @keyframes — how to build a dual-layer spinning conic-gradient border using ::before and ::after pseudo-elements — how to create a hover-triggered card expansion with pure CSS transitions — how to build a glass panel with inset box-shadow — how to slide form content in with translateY — how to structure all design tokens with CSS custom properties for a fully re-themeable component.
How the Border Spin Works
The spinning border is not an actual border — it is a background. The .card element has a repeating-conic-gradient as its background, which produces alternating colored and transparent arcs radiating from the center. When the --angle variable animates from 0deg to 360deg, the entire gradient rotates, making the colored arcs sweep around the card like a spinning ring.
The "border" is just a conic-gradient background peeking out from behind a dark ::after fill — there is no actual CSS border involved.
The dark inner fill is a ::after pseudo-element with inset: 4px — it sits on top of the gradient background and covers everything except a 4px ring around the edge. That 4px gap is the visible spinning border. The two gradient layers — pink on .card and cyan on .card::before — are offset by animation-delay: -1s, making them appear to interweave as they spin.
Step 1 — CSS Custom Properties
All values live in :root as custom properties. Tokens are grouped into palette, geometry, and motion categories. Changing the theme — for example swapping the pink/cyan pair for gold/white — requires editing only this block.
:root {
/* palette */
--pink: #ff2770;
--cyan: #45f3ff;
--bg: #151f28;
--card-fill: #2d2d39;
--card-border: #25252b;
--white: #ffffff;
--placeholder: #999999;
--text-dark: #111111;
/* geometry */
--card-w: 400px;
--card-h: 200px;
--card-w-open: 450px;
--card-h-open: 500px;
/* motion */
--speed: 0.5s;
--spin-speed: 4s;
}Keeping size tokens (--card-w, --card-h) separate from motion tokens (--speed, --spin-speed) means you can slow down all animations at once — useful for reduced-motion media queries — without touching the layout dimensions at all. Set --speed: 0s and --spin-speed: 0s inside a @media (prefers-reduced-motion: reduce) block to instantly disable all animation.
Step 2 — The Markup
The component is three nested divs deep. .card is the spinning border wrapper and the hover trigger. .panel is the glass content surface. .content is the form group that slides in on hover. Inputs and links live directly inside .content.
<div class="card">
<div class="panel">
<div class="content">
<h2>
<!-- SVG icon: login arrow -->
<svg class="icon" ...>...</svg>
Login
<!-- SVG icon: heart -->
<svg class="icon" ...>...</svg>
</h2>
<input type="text" placeholder="Username" autocomplete="username" />
<input type="password" placeholder="Password" autocomplete="current-password" />
<input type="submit" value="Sign in" />
<div class="links">
<a href="#">Forgot Password</a>
<a href="#">Sign up</a>
</div>
</div>
</div>
</div>| Element | Role | Animated |
|---|---|---|
| .card | Spinning border host and hover trigger | Yes — spin 4s, width/height on hover |
| .card::before | Cyan conic-gradient layer, offset −1s | Yes — spin 4s, delay −1s |
| .card::after | Dark inner fill — creates the border illusion | No |
| .panel | Glass content surface — inset shrinks on hover | Yes — inset transition 0.5s |
| .content | Form group — slides up from translateY(126px) on hover | Yes — transform transition 0.5s |
| h2 .icon | Inline SVG icons — pink glow via drop-shadow filter | No |
| input[type=submit] | Cyan submit button — cyan glow on hover | Yes — box-shadow transition 0.5s |
Step 3 — Animating @property
Standard CSS cannot animate a custom property used inside conic-gradient — the browser has no idea the value is an angle. The @property at-rule solves this by registering the variable with a type. Once the browser knows --angle is an <angle>, it can interpolate it smoothly across keyframes.
/* Register the angle as an animatable typed property */
@property --angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
/* Simple from/to keyframe — browser interpolates the angle */
@keyframes spin {
from { --angle: 0deg; }
to { --angle: 360deg; }
}
/* Apply to the card */
.card {
animation: spin var(--spin-speed) linear infinite;
}Setting inherits: false prevents the --angle value from cascading down to child elements. Since each gradient layer needs its own independent animation (the cyan layer is offset by −1s), you need both pseudo-elements to each consume the same --angle property but animate it independently. With inherits: false, each element animates its own copy rather than inheriting a shared value from a parent.
Step 4 — Stacking Two Gradient Layers
The woven two-color effect comes from two elements stacked on the same position: the .card background carries the pink gradient, and .card::before carries the cyan gradient. Both animate at the same speed, but the ::before layer has animation-delay: -1s — meaning it starts one second ahead, so the two sweeps are always offset from each other.
/* Pink layer — on the card itself */
.card {
background: repeating-conic-gradient(
from var(--angle),
var(--pink) 0%,
var(--pink) 5%,
transparent 5%,
transparent 40%,
var(--pink) 50%
);
animation: spin var(--spin-speed) linear infinite;
}
/* Cyan layer — on ::before, offset by -1s */
.card::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: repeating-conic-gradient(
from var(--angle),
var(--cyan) 0%,
var(--cyan) 5%,
transparent 5%,
transparent 40%,
var(--cyan) 50%
);
animation: spin var(--spin-speed) linear infinite;
animation-delay: -1s;
}
/* Dark fill on ::after — creates the 4px border illusion */
.card::after {
content: "";
position: absolute;
inset: 4px;
border-radius: 15px;
background: var(--card-fill);
border: 8px solid var(--card-border);
}Using border-radius: inherit on ::before means you only ever need to change the radius in one place — on .card — and the pseudo-element always matches automatically.
Step 5 — The Glass Panel
The .panel sits as an absolute child inside .card, above the ::after dark fill via z-index: 1. Its semi-transparent rgba(0,0,0,0.2) background and inset 0 10px 20px box-shadow create a recessed glass appearance. The border-bottom gives it a subtle lit edge — a classic glassmorphism detail.
.panel {
position: absolute;
inset: 60px; /* collapses to 40px on hover */
z-index: 1;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
border-radius: 10px;
background: rgba(0, 0, 0, 0.2);
color: var(--white);
box-shadow: inset 0 10px 20px rgba(0, 0, 0, 0.5);
border-bottom: 2px solid rgba(255, 255, 255, 0.5);
overflow: hidden;
transition: inset var(--speed);
}Step 6 — Hover Expand & Slide-In
Two things happen simultaneously on hover: the card expands from 400×200px to 450×500px, and the .content div slides up from translateY(126px) to translateY(0). The 126px offset is exactly enough to push the content just below the bottom edge of the collapsed panel, keeping it hidden inside the overflow: hidden panel until hover.
/* Expand the card */
.card:hover {
width: var(--card-w-open); /* 450px */
height: var(--card-h-open); /* 500px */
}
/* Shrink the panel inset — gives more breathing room */
.card:hover .panel {
inset: 40px;
}
/* Slide the form content up into view */
.card:hover .content {
transform: translateY(0);
}
/* Form content — starts pushed below visible area */
.content {
width: 70%;
transform: translateY(126px);
transition: transform var(--speed);
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}With inset: 60px on the panel inside a 200px tall card, the visible panel height is 200 − 60 − 60 = 80px. The form content is taller than that, so any translateY value larger than ~80px hides it. 126px gives a comfortable margin — the heading sits right at the center of the 80px panel height when the content is pushed down that amount, so the heading is visible when collapsed and the full form slides in on hover.
Step 7 — Input Fields & Submit Button
All inputs share a common style: semi-transparent dark background, white border, 30px border-radius pill shape. The submit button overrides these defaults with the cyan accent color. The cyan glow on hover is a simple box-shadow transition — two shadows, inner and outer, both using var(--cyan).
/* Base input styles */
.content input {
width: 100%;
padding: 10px 20px;
font-size: 1em;
color: var(--white);
background: rgba(0, 0, 0, 0.1);
border: 2px solid var(--white);
border-radius: 30px;
outline: none;
}
.content input::placeholder {
color: var(--placeholder);
}
/* Submit button — cyan override */
.content input[type="submit"] {
background: var(--cyan);
border-color: transparent;
color: var(--text-dark);
font-weight: 500;
cursor: pointer;
transition: box-shadow var(--speed);
}
.content input[type="submit"]:hover {
box-shadow: 0 0 10px var(--cyan), 0 0 60px var(--cyan);
}
/* Footer links */
.links {
width: 100%;
display: flex;
justify-content: space-between;
}
.links a { color: var(--white); text-decoration: none; font-size: 0.9em; }
.links a:last-child { color: var(--pink); font-weight: 600; }The icons in the heading use inline SVG paths copied directly from the Font Awesome icon set, instead of loading the FA web font CDN. This removes an external network dependency entirely — the icons render instantly from embedded path data with zero font loading required. The pink glow is applied via filter: drop-shadow() on the SVG elements since text-shadow does not apply to SVG.
Tuning Reference
| Token / Property | Default | Effect |
|---|---|---|
| --spin-speed | 4s | Border rotation speed. Lower = faster spin. Above 8s feels static |
| --speed | 0.5s | Hover transition speed. Applies to card expand, panel inset, and content slide |
| --card-w-open | 450px | Expanded card width on hover |
| --card-h-open | 500px | Expanded card height on hover — must be tall enough for the full form |
| --pink | #ff2770 | Pink border layer color. Also used for heading icons and Sign up link |
| --cyan | #45f3ff | Cyan border layer color. Also used for submit button and glow |
| animation-delay | -1s | Phase offset between pink and cyan layers. Change to alter weave pattern |
| inset on .panel | 60px | Controls visible panel height when collapsed — reduce to show more content |
| translateY on .content | 126px | Push distance for hidden form. Must exceed collapsed panel height |
| inset: 4px on ::after | 4px | Visible border thickness. Increase for a bolder border ring |
| gap on .content | 20px | Spacing between form fields |
Full Source Code
Save the following as animated-border-login.html and open in any modern browser. Zero dependencies, zero build step, zero JavaScript.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/animated-border-login.htmlEvery effect in BorderLogin — the spinning border, the hover expand, the slide-in reveal — is pure CSS. Not a single line of JavaScript required.