Magnetic button
CSS + JSInside its field the button shifts and leans toward the pointer while its label floats higher, so the two slide apart. JS only reports the pointer position; release it and a springy transition takes it home.
Five thin star plates, each a front and a gold back with an edge between them. Radio inputs and labels choose a rating, and every star up to it flips over, one after another.
Your edited version
clip-path star stacked 1px apart: the front (plain) and back (gold) faces with three edge layers between them, so a turning star shows a real thickness.rotateY(180deg) translateZ(2px): it faces the other way, and once the star turns half round it reads correctly. backface-visibility: hidden on both faces means only the one facing you is drawn.~ can reach everything after them. Rating r is one rule: #star-r:checked ~ .stars label:nth-child(-n + r) selects the first r stars, which turn over.transition-delay: calc(var(--i) * 90ms) lights them left to right; the resting style has the reverse delay, so a lower rating empties the row from the right. The delay in the state you go to is the one that counts.radio:nth-of-type(n):checked ~ label:nth-child(-n + n)clip-path star faces, stacked edge layersrotateY(180deg) + backface-visibilitytransition-delay from --i<div class="rating" role="radiogroup" aria-label="Rate this effect">
<b class="title" aria-hidden="true">Rate this effect</b>
<input type="radio" name="rating" id="star-1" aria-label="1 star, Poor" />
<input type="radio" name="rating" id="star-2" aria-label="2 stars, Okay" />
<input type="radio" name="rating" id="star-3" aria-label="3 stars, Good" />
<input type="radio" name="rating" id="star-4" aria-label="4 stars, Great" checked />
<input type="radio" name="rating" id="star-5" aria-label="5 stars, Amazing!" />
<div class="stars">
<label for="star-1" style="--i:0"><span class="lift"><span class="star"><i></i><i></i><i></i><i></i><i></i></span></span></label>
<label for="star-2" style="--i:1"><span class="lift"><span class="star"><i></i><i></i><i></i><i></i><i></i></span></span></label>
<label for="star-3" style="--i:2"><span class="lift"><span class="star"><i></i><i></i><i></i><i></i><i></i></span></span></label>
<label for="star-4" style="--i:3"><span class="lift"><span class="star"><i></i><i></i><i></i><i></i><i></i></span></span></label>
<label for="star-5" style="--i:4"><span class="lift"><span class="star"><i></i><i></i><i></i><i></i><i></i></span></span></label>
</div>
<p class="words" aria-hidden="true"><span>Tap a star</span><span>Poor</span><span>Okay</span><span>Good</span><span>Great</span><span>Amazing!</span></p>
</div>
.rating {
display: grid;
justify-items: center;
gap: 12px;
perspective: 800px;
transform-style: preserve-3d;
}
/* the real radios stay (arrow keys, forms, screen readers), only invisible */
.rating input {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
opacity: 0;
pointer-events: none;
}
.title {
color: #949bc0;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.stars {
display: flex;
gap: 9px;
transform-style: preserve-3d;
}
/* the label is the static hit target; only the star inside it moves */
.stars label {
position: relative;
width: 34px;
height: 34px;
border-radius: 8px;
cursor: pointer;
transform-style: preserve-3d;
}
/* a ring that bursts once as the star lands gold */
.stars label::before {
content: '';
position: absolute;
inset: -5px;
border: 2px solid #ffb547;
border-radius: 50%;
opacity: 0;
pointer-events: none;
}
/* a warm glow behind a gold star */
.stars label::after {
content: '';
position: absolute;
inset: -10px;
border-radius: 50%;
background: radial-gradient(closest-side, rgb(255 181 71 / 0.45), transparent);
opacity: 0;
pointer-events: none;
transform: translateZ(-6px);
transition: opacity 0.3s;
}
.lift {
position: absolute;
inset: 0;
pointer-events: none;
transform-style: preserve-3d;
transition: transform 0.25s cubic-bezier(0.3, 1.6, 0.5, 1);
}
label:hover .lift {
transform: translateZ(12px) scale(1.14);
}
.star {
position: absolute;
inset: 0;
transform-style: preserve-3d;
transition: transform 0.7s cubic-bezier(0.35, 1.45, 0.5, 1);
transition-delay: calc((4 - var(--i)) * 60ms); /* turning back: the last star first */
}
/* five copies of one star shape, 1px apart */
.star i {
position: absolute;
inset: 0;
clip-path: polygon(50% 2%, 61.5% 36%, 97% 36.5%, 68.5% 57.5%, 79% 92%, 50% 71%, 21% 92%, 31.5% 57.5%, 3% 36.5%, 38.5% 36%);
background: color-mix(in srgb, #ffb547 40%, #1b1408); /* the edge */
}
.star i:nth-child(2) { transform: translateZ(1px); }
.star i:nth-child(4) { transform: translateZ(-1px); }
/* front: a darker star inside a lighter one, so it looks outlined */
.star i:nth-child(1) {
background: color-mix(in srgb, #eceefb 38%, #141830);
backface-visibility: hidden;
transform: translateZ(2px);
}
.star i:nth-child(1)::before {
content: '';
position: absolute;
inset: 3px;
clip-path: inherit;
background: color-mix(in srgb, #eceefb 12%, #141830);
}
/* back: gold, facing the other way */
.star i:nth-child(5) {
background: linear-gradient(160deg, #ffe2b8 10%, #ffb547 45%, #e58a2e 90%);
backface-visibility: hidden;
transform: rotateY(180deg) translateZ(2px);
}
/* rating r turns stars 1 to r, one after another */
#star-1:checked ~ .stars label:nth-child(-n + 1) .star,
#star-2:checked ~ .stars label:nth-child(-n + 2) .star,
#star-3:checked ~ .stars label:nth-child(-n + 3) .star,
#star-4:checked ~ .stars label:nth-child(-n + 4) .star,
#star-5:checked ~ .stars label:nth-child(-n + 5) .star {
transform: rotateY(180deg);
transition-delay: calc(var(--i) * 90ms);
}
#star-1:checked ~ .stars label:nth-child(-n + 1)::after,
#star-2:checked ~ .stars label:nth-child(-n + 2)::after,
#star-3:checked ~ .stars label:nth-child(-n + 3)::after,
#star-4:checked ~ .stars label:nth-child(-n + 4)::after,
#star-5:checked ~ .stars label:nth-child(-n + 5)::after {
opacity: 1;
transition-delay: calc(var(--i) * 90ms + 200ms);
}
/* a newly matching rule starts its animation: the ring bursts once per star lit */
#star-1:checked ~ .stars label:nth-child(-n + 1)::before,
#star-2:checked ~ .stars label:nth-child(-n + 2)::before,
#star-3:checked ~ .stars label:nth-child(-n + 3)::before,
#star-4:checked ~ .stars label:nth-child(-n + 4)::before,
#star-5:checked ~ .stars label:nth-child(-n + 5)::before {
animation: burst 0.65s calc(var(--i) * 90ms + 250ms) ease-out both;
}
@keyframes burst {
from { opacity: 0.9; transform: translateZ(-4px) scale(0.5); }
to { opacity: 0; transform: translateZ(-4px) scale(1.5); }
}
/* keyboard focus: ring the star whose radio has focus */
#star-1:focus-visible ~ .stars label:nth-child(1),
#star-2:focus-visible ~ .stars label:nth-child(2),
#star-3:focus-visible ~ .stars label:nth-child(3),
#star-4:focus-visible ~ .stars label:nth-child(4),
#star-5:focus-visible ~ .stars label:nth-child(5) {
outline: 2px solid #2ee6d6;
outline-offset: 5px;
}
/* the caption: all words in one grid cell, only the chosen one shows */
.words {
display: grid;
margin: 0;
color: #ffb547;
font-size: 14px;
font-weight: 800;
}
.words span {
grid-area: 1 / 1;
justify-self: center;
opacity: 0;
transform: translateY(5px);
transition: opacity 0.25s, transform 0.25s;
}
.words span:first-child {
color: #949bc0;
opacity: 1;
transform: none;
}
.rating input:checked ~ .words span:first-child {
opacity: 0;
transform: translateY(-5px);
}
#star-1:checked ~ .words span:nth-child(2),
#star-2:checked ~ .words span:nth-child(3),
#star-3:checked ~ .words span:nth-child(4),
#star-4:checked ~ .words span:nth-child(5),
#star-5:checked ~ .words span:nth-child(6) {
opacity: 1;
transform: none;
}
$n: 5; // stars
$size: 34px;
$star: polygon(50% 2%, 61.5% 36%, 97% 36.5%, 68.5% 57.5%, 79% 92%, 50% 71%, 21% 92%, 31.5% 57.5%, 3% 36.5%, 38.5% 36%);
.d-rating {
display: grid;
justify-items: center;
gap: 12px;
color: var(--text);
transform-style: preserve-3d;
// The real radios stay in the page (arrow keys, forms, screen readers); only invisible.
input {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
opacity: 0;
pointer-events: none;
}
&__title {
color: var(--muted);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
&__stars {
display: flex;
gap: 9px;
transform-style: preserve-3d;
}
// The label is the static hit target; only the star inside it moves.
label {
position: relative;
width: $size;
height: $size;
border-radius: 8px;
cursor: pointer;
transform-style: preserve-3d;
// a ring that bursts out once when the star lands gold (see the @for below)
&::before {
content: '';
position: absolute;
inset: -5px;
border: 2px solid var(--warm);
border-radius: 50%;
opacity: 0;
pointer-events: none;
}
// a warm glow behind a gold star
&::after {
content: '';
position: absolute;
inset: -10px;
border-radius: 50%;
background: radial-gradient(closest-side, color-mix(in srgb, var(--warm) 45%, transparent), transparent);
opacity: 0;
pointer-events: none;
transform: translateZ(-6px);
transition: opacity 0.3s;
}
}
&__lift {
position: absolute;
inset: 0;
pointer-events: none;
transform-style: preserve-3d;
transition: transform 0.25s cubic-bezier(0.3, 1.6, 0.5, 1);
}
label:hover &__lift {
transform: translateZ(12px) scale(1.14);
}
// One star: five copies of the same clip-path shape stacked 1px apart. The front and back are
// the faces; the three in between are the edge you see while it turns.
&__star {
position: absolute;
inset: 0;
transform-style: preserve-3d;
transition: transform 0.7s cubic-bezier(0.35, 1.45, 0.5, 1);
// Going back to plain, the last star turns first: the row empties from the right.
transition-delay: calc((#{$n - 1} - var(--i)) * 60ms);
i {
position: absolute;
inset: 0;
clip-path: $star;
background: color-mix(in srgb, var(--warm) 40%, #1b1408);
}
// front: a plain star, drawn as a darker star inside a lighter one to look outlined
i:nth-child(1) {
background: color-mix(in srgb, var(--text) 38%, var(--surface-solid));
backface-visibility: hidden;
transform: translateZ(2px);
&::before {
content: '';
position: absolute;
inset: 3px;
clip-path: $star;
background: color-mix(in srgb, var(--text) 12%, var(--surface-solid));
}
}
i:nth-child(2) {
transform: translateZ(1px);
}
i:nth-child(4) {
transform: translateZ(-1px);
}
// back: gold, turned to face the other way so it reads right once the star is flipped
i:nth-child(5) {
background:
linear-gradient(160deg, color-mix(in srgb, var(--warm), #fff 55%) 10%, var(--warm) 45%, color-mix(in srgb, var(--warm), #b33c00 35%) 90%);
backface-visibility: hidden;
transform: rotateY(180deg) translateZ(2px);
}
}
// the caption under the stars: every word sits in the same cell, only the chosen one shows
&__words {
display: grid;
margin: 0;
color: var(--warm);
font-size: 14px;
font-weight: 800;
span {
grid-area: 1 / 1;
justify-self: center;
opacity: 0;
transform: translateY(5px);
transition:
opacity 0.25s,
transform 0.25s;
}
span:first-child {
color: var(--muted);
opacity: 1;
transform: none;
}
}
input:checked ~ &__words span:first-child {
opacity: 0;
transform: translateY(-5px);
}
}
// Rating r lights stars 1 to r. The chosen radio is the r-th input; ~ reaches the stars after it.
@for $r from 1 through $n {
.d-rating input:nth-of-type(#{$r}):checked ~ .d-rating__stars label:nth-child(-n + #{$r}) {
&::before {
animation: d-rating-burst 0.65s calc(var(--i) * 90ms + 250ms) ease-out both;
}
&::after {
opacity: 1;
transition-delay: calc(var(--i) * 90ms + 200ms);
}
.d-rating__star {
transform: rotateY(180deg);
transition-delay: calc(var(--i) * 90ms); // lighting up: left to right
}
}
.d-rating input:nth-of-type(#{$r}):checked ~ .d-rating__words span:nth-child(#{$r + 1}) {
opacity: 1;
transform: none;
}
.d-rating input:nth-of-type(#{$r}):focus-visible ~ .d-rating__stars label:nth-child(#{$r}) {
outline: 2px solid var(--accent-2);
outline-offset: 5px;
}
}
@keyframes d-rating-burst {
from {
opacity: 0.9;
transform: translateZ(-4px) scale(0.5);
}
to {
opacity: 0;
transform: translateZ(-4px) scale(1.5);
}
}