30 CSS Shake Animations14 / 30
Disabled Button Shake CSS
The disabled-button problem solved twice: a pure-CSS version where hovering the wrapper makes the dead button shake its head and float its reason ('accept the terms first'), and the full JavaScript pattern that catches clicks on a wrapper — because disabled buttons swallow their own click events — then shakes, explains, and unlocks live when the checklist completes.
Published
The code
<div class="shk-14-group">
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@500..800&display=swap" rel="stylesheet">
<section class="shk-14a" aria-label="Disabled button that shakes its head on hover">
<div class="shk-14a__card">
<h2 class="shk-14a__title">Almost there</h2>
<label class="shk-14a__check"><input class="shk-14a__box" id="shk-14a-terms" type="checkbox"><span>I've read the <b>terms of service</b></span></label>
<span class="shk-14a__gate">
<button class="shk-14a__btn" type="button" disabled>Create account</button>
<span class="shk-14a__tip" role="tooltip">Accept the terms first</span>
</span>
<p class="shk-14a__hint" aria-hidden="true">hover the dead button — it shakes “no”; tick the box and CSS :has() wakes it up</p>
</div>
</section>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400..700&display=swap" rel="stylesheet">
<section class="shk-14b" aria-label="Disabled deploy button that shakes and explains when clicked">
<div class="shk-14b__card">
<div class="shk-14b__head"><h2 class="shk-14b__title">Deploy to production</h2><span class="shk-14b__env">v2.4.1</span></div>
<ul class="shk-14b__list" id="shk-14b-list">
<li><label class="shk-14b__item"><input type="checkbox" checked> Tests passing <em>412/412</em></label></li>
<li><label class="shk-14b__item"><input type="checkbox" id="shk-14b-review"> Code review approved</label></li>
<li><label class="shk-14b__item"><input type="checkbox" id="shk-14b-migr"> Migrations dry-run</label></li>
</ul>
<span class="shk-14b__gate" id="shk-14b-gate">
<button class="shk-14b__btn" id="shk-14b-btn" type="button" disabled>Deploy</button>
</span>
<p class="shk-14b__why" id="shk-14b-why" aria-live="polite">2 checks remaining</p>
</div>
</section>
</div><div class="shk-14-group">
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@500..800&display=swap" rel="stylesheet">
<section class="shk-14a" aria-label="Disabled button that shakes its head on hover">
<div class="shk-14a__card">
<h2 class="shk-14a__title">Almost there</h2>
<label class="shk-14a__check"><input class="shk-14a__box" id="shk-14a-terms" type="checkbox"><span>I've read the <b>terms of service</b></span></label>
<span class="shk-14a__gate">
<button class="shk-14a__btn" type="button" disabled>Create account</button>
<span class="shk-14a__tip" role="tooltip">Accept the terms first</span>
</span>
<p class="shk-14a__hint" aria-hidden="true">hover the dead button — it shakes “no”; tick the box and CSS :has() wakes it up</p>
</div>
</section>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400..700&display=swap" rel="stylesheet">
<section class="shk-14b" aria-label="Disabled deploy button that shakes and explains when clicked">
<div class="shk-14b__card">
<div class="shk-14b__head"><h2 class="shk-14b__title">Deploy to production</h2><span class="shk-14b__env">v2.4.1</span></div>
<ul class="shk-14b__list" id="shk-14b-list">
<li><label class="shk-14b__item"><input type="checkbox" checked> Tests passing <em>412/412</em></label></li>
<li><label class="shk-14b__item"><input type="checkbox" id="shk-14b-review"> Code review approved</label></li>
<li><label class="shk-14b__item"><input type="checkbox" id="shk-14b-migr"> Migrations dry-run</label></li>
</ul>
<span class="shk-14b__gate" id="shk-14b-gate">
<button class="shk-14b__btn" id="shk-14b-btn" type="button" disabled>Deploy</button>
</span>
<p class="shk-14b__why" id="shk-14b-why" aria-live="polite">2 checks remaining</p>
</div>
</section>
</div>.shk-14-group {
display: flex;
flex-wrap: wrap;
align-items: stretch;
width: 100%;
}
.shk-14-group > section {
flex: 1 1 480px;
min-width: min(100%,360px);
}
.shk-14a,
.shk-14a *,
.shk-14a *::before,
.shk-14a *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.shk-14a {
--ink: #1d2025;
width: 100%;
min-height: 100vh;
display: grid;
place-items: center;
padding: 48px 24px;
background: linear-gradient(180deg,#f4f5f6,#e9ebed);
font-family: 'Manrope','Segoe UI',system-ui,sans-serif;
}
.shk-14a__card {
width: min(400px,100%);
padding: 36px;
border-radius: 20px;
background: #fff;
border: 1px solid rgba(29,32,37,.1);
box-shadow: 0 30px 70px -40px rgba(29,32,37,.45);
display: grid;
gap: 18px;
}
.shk-14a__title {
font-size: 23px;
font-weight: 800;
letter-spacing: -.01em;
color: var(--ink);
}
.shk-14a__check {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
color: rgba(29,32,37,.75);
cursor: pointer;
user-select: none;
}
.shk-14a__check b {
color: var(--ink);
}
.shk-14a__box {
width: 19px;
height: 19px;
accent-color: var(--ink);
cursor: pointer;
}
.shk-14a__gate {
position: relative;
display: block;
}
.shk-14a__btn {
width: 100%;
padding: 15px;
font: inherit;
font-size: 15px;
font-weight: 800;
border: 0;
border-radius: 13px;
color: #fff;
background: var(--ink);
cursor: pointer;
transition: opacity .25s,background .25s,scale .12s;
}
.shk-14a__btn:not(:disabled):hover {
background: #31353c;
}
.shk-14a__btn:not(:disabled):active {
scale: .98;
}
.shk-14a__btn:disabled {
opacity: .45;
cursor: not-allowed;
}
.shk-14a__gate:hover .shk-14a__btn:disabled {
animation: shk-14a-headshake .5s ease-in-out;
}
.shk-14a__tip {
position: absolute;
left: 50%;
bottom: calc(100% + 10px);
translate: -50% 4px;
white-space: nowrap;
font-size: 12px;
font-weight: 700;
color: #fff;
background: var(--ink);
padding: 7px 12px;
border-radius: 8px;
opacity: 0;
pointer-events: none;
transition: opacity .2s,translate .2s;
}
.shk-14a__tip::after {
content: "";
position: absolute;
left: 50%;
top: 100%;
translate: -50% 0;
border: 5px solid transparent;
border-top-color: var(--ink);
}
.shk-14a__gate:hover .shk-14a__tip {
opacity: 1;
translate: -50% 0;
}
.shk-14a__card:has(.shk-14a__box:checked) .shk-14a__btn {
opacity: 1;
cursor: pointer;
background: oklch(0.55 0.15 155);
}
.shk-14a__card:has(.shk-14a__box:checked) .shk-14a__gate:hover .shk-14a__btn {
animation: shk-14a-nod .45s cubic-bezier(.34,1.56,.64,1);
}
.shk-14a__card:has(.shk-14a__box:checked) .shk-14a__tip {
display: none;
}
.shk-14a__hint {
font-size: 11.5px;
color: rgba(29,32,37,.45);
}
@keyframes shk-14a-headshake {
0%,
100% {
rotate: 0deg;
}
20% {
rotate: -4deg;
}
45% {
rotate: 3.5deg;
}
70% {
rotate: -2deg;
}
88% {
rotate: 1deg;
}
}
@keyframes shk-14a-nod {
30% {
translate: 0 5px;
}
65% {
translate: 0 -3px;
}
88% {
translate: 0 1px;
}
}
@media (prefers-reduced-motion: reduce) {
.shk-14a__gate:hover .shk-14a__btn:disabled,
.shk-14a__card:has(.shk-14a__box:checked) .shk-14a__gate:hover .shk-14a__btn {
animation: none;
}
}
.shk-14b,
.shk-14b *,
.shk-14b *::before,
.shk-14b *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.shk-14b {
--ok: oklch(0.75 0.16 155);
width: 100%;
min-height: 100vh;
display: grid;
place-items: center;
padding: 48px 24px;
background: linear-gradient(170deg,#12151c,#0b0d12);
font-family: 'Space Grotesk','Segoe UI',system-ui,sans-serif;
}
.shk-14b__card {
width: min(420px,100%);
padding: 30px;
border-radius: 18px;
background: rgba(255,255,255,.045);
border: 1px solid rgba(160,185,220,.15);
box-shadow: 0 40px 90px -50px rgba(0,0,0,.9);
}
.shk-14b__head {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.shk-14b__title {
font-size: 19px;
font-weight: 700;
color: #e9eefa;
letter-spacing: -.01em;
}
.shk-14b__env {
font-size: 12px;
font-weight: 600;
color: rgba(210,225,250,.5);
}
.shk-14b__list {
margin-top: 18px;
list-style: none;
display: grid;
gap: 9px;
}
.shk-14b__item {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
color: rgba(226,234,250,.8);
padding: 11px 13px;
background: rgba(8,11,16,.5);
border: 1px solid rgba(160,185,220,.12);
border-radius: 11px;
cursor: pointer;
}
.shk-14b__item input {
width: 17px;
height: 17px;
accent-color: oklch(0.75 0.16 155);
}
.shk-14b__item em {
margin-left: auto;
font-style: normal;
font-size: 11.5px;
color: rgba(210,225,250,.45);
}
.shk-14b__gate {
display: block;
margin-top: 18px;
}
.shk-14b__btn {
width: 100%;
padding: 15px;
font: inherit;
font-size: 15px;
font-weight: 700;
border: 0;
border-radius: 12px;
color: #0b0d12;
background: var(--ok);
cursor: pointer;
transition: opacity .25s,filter .2s,scale .12s;
}
.shk-14b__btn:disabled {
opacity: .32;
cursor: not-allowed;
}
.shk-14b__btn:not(:disabled):hover {
filter: brightness(1.08);
}
.shk-14b__btn:not(:disabled):active {
scale: .98;
}
.shk-14b__gate.is-nope .shk-14b__btn {
animation: shk-14b-shake .5s cubic-bezier(.36,.07,.19,.97);
}
.shk-14b__why {
margin-top: 12px;
font-size: 12.5px;
color: rgba(210,225,250,.5);
text-align: center;
min-height: 18px;
}
.shk-14b__why.err {
color: oklch(0.78 0.13 25);
font-weight: 600;
}
.shk-14b__why.ok {
color: var(--ok);
font-weight: 600;
}
@keyframes shk-14b-shake {
15% {
translate: -7px 0;
}
32% {
translate: 6px 0;
}
50% {
translate: -4px 0;
}
68% {
translate: 2px 0;
}
85% {
translate: -1px 0;
}
}
@media (prefers-reduced-motion: reduce) {
.shk-14b__gate.is-nope .shk-14b__btn {
animation: none;
}
}.shk-14-group {
display: flex;
flex-wrap: wrap;
align-items: stretch;
width: 100%;
}
.shk-14-group > section {
flex: 1 1 480px;
min-width: min(100%,360px);
}
.shk-14a,
.shk-14a *,
.shk-14a *::before,
.shk-14a *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.shk-14a {
--ink: #1d2025;
width: 100%;
min-height: 100vh;
display: grid;
place-items: center;
padding: 48px 24px;
background: linear-gradient(180deg,#f4f5f6,#e9ebed);
font-family: 'Manrope','Segoe UI',system-ui,sans-serif;
}
.shk-14a__card {
width: min(400px,100%);
padding: 36px;
border-radius: 20px;
background: #fff;
border: 1px solid rgba(29,32,37,.1);
box-shadow: 0 30px 70px -40px rgba(29,32,37,.45);
display: grid;
gap: 18px;
}
.shk-14a__title {
font-size: 23px;
font-weight: 800;
letter-spacing: -.01em;
color: var(--ink);
}
.shk-14a__check {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
color: rgba(29,32,37,.75);
cursor: pointer;
user-select: none;
}
.shk-14a__check b {
color: var(--ink);
}
.shk-14a__box {
width: 19px;
height: 19px;
accent-color: var(--ink);
cursor: pointer;
}
.shk-14a__gate {
position: relative;
display: block;
}
.shk-14a__btn {
width: 100%;
padding: 15px;
font: inherit;
font-size: 15px;
font-weight: 800;
border: 0;
border-radius: 13px;
color: #fff;
background: var(--ink);
cursor: pointer;
transition: opacity .25s,background .25s,scale .12s;
}
.shk-14a__btn:not(:disabled):hover {
background: #31353c;
}
.shk-14a__btn:not(:disabled):active {
scale: .98;
}
.shk-14a__btn:disabled {
opacity: .45;
cursor: not-allowed;
}
.shk-14a__gate:hover .shk-14a__btn:disabled {
animation: shk-14a-headshake .5s ease-in-out;
}
.shk-14a__tip {
position: absolute;
left: 50%;
bottom: calc(100% + 10px);
translate: -50% 4px;
white-space: nowrap;
font-size: 12px;
font-weight: 700;
color: #fff;
background: var(--ink);
padding: 7px 12px;
border-radius: 8px;
opacity: 0;
pointer-events: none;
transition: opacity .2s,translate .2s;
}
.shk-14a__tip::after {
content: "";
position: absolute;
left: 50%;
top: 100%;
translate: -50% 0;
border: 5px solid transparent;
border-top-color: var(--ink);
}
.shk-14a__gate:hover .shk-14a__tip {
opacity: 1;
translate: -50% 0;
}
.shk-14a__card:has(.shk-14a__box:checked) .shk-14a__btn {
opacity: 1;
cursor: pointer;
background: oklch(0.55 0.15 155);
}
.shk-14a__card:has(.shk-14a__box:checked) .shk-14a__gate:hover .shk-14a__btn {
animation: shk-14a-nod .45s cubic-bezier(.34,1.56,.64,1);
}
.shk-14a__card:has(.shk-14a__box:checked) .shk-14a__tip {
display: none;
}
.shk-14a__hint {
font-size: 11.5px;
color: rgba(29,32,37,.45);
}
@keyframes shk-14a-headshake {
0%,
100% {
rotate: 0deg;
}
20% {
rotate: -4deg;
}
45% {
rotate: 3.5deg;
}
70% {
rotate: -2deg;
}
88% {
rotate: 1deg;
}
}
@keyframes shk-14a-nod {
30% {
translate: 0 5px;
}
65% {
translate: 0 -3px;
}
88% {
translate: 0 1px;
}
}
@media (prefers-reduced-motion: reduce) {
.shk-14a__gate:hover .shk-14a__btn:disabled,
.shk-14a__card:has(.shk-14a__box:checked) .shk-14a__gate:hover .shk-14a__btn {
animation: none;
}
}
.shk-14b,
.shk-14b *,
.shk-14b *::before,
.shk-14b *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.shk-14b {
--ok: oklch(0.75 0.16 155);
width: 100%;
min-height: 100vh;
display: grid;
place-items: center;
padding: 48px 24px;
background: linear-gradient(170deg,#12151c,#0b0d12);
font-family: 'Space Grotesk','Segoe UI',system-ui,sans-serif;
}
.shk-14b__card {
width: min(420px,100%);
padding: 30px;
border-radius: 18px;
background: rgba(255,255,255,.045);
border: 1px solid rgba(160,185,220,.15);
box-shadow: 0 40px 90px -50px rgba(0,0,0,.9);
}
.shk-14b__head {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.shk-14b__title {
font-size: 19px;
font-weight: 700;
color: #e9eefa;
letter-spacing: -.01em;
}
.shk-14b__env {
font-size: 12px;
font-weight: 600;
color: rgba(210,225,250,.5);
}
.shk-14b__list {
margin-top: 18px;
list-style: none;
display: grid;
gap: 9px;
}
.shk-14b__item {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
color: rgba(226,234,250,.8);
padding: 11px 13px;
background: rgba(8,11,16,.5);
border: 1px solid rgba(160,185,220,.12);
border-radius: 11px;
cursor: pointer;
}
.shk-14b__item input {
width: 17px;
height: 17px;
accent-color: oklch(0.75 0.16 155);
}
.shk-14b__item em {
margin-left: auto;
font-style: normal;
font-size: 11.5px;
color: rgba(210,225,250,.45);
}
.shk-14b__gate {
display: block;
margin-top: 18px;
}
.shk-14b__btn {
width: 100%;
padding: 15px;
font: inherit;
font-size: 15px;
font-weight: 700;
border: 0;
border-radius: 12px;
color: #0b0d12;
background: var(--ok);
cursor: pointer;
transition: opacity .25s,filter .2s,scale .12s;
}
.shk-14b__btn:disabled {
opacity: .32;
cursor: not-allowed;
}
.shk-14b__btn:not(:disabled):hover {
filter: brightness(1.08);
}
.shk-14b__btn:not(:disabled):active {
scale: .98;
}
.shk-14b__gate.is-nope .shk-14b__btn {
animation: shk-14b-shake .5s cubic-bezier(.36,.07,.19,.97);
}
.shk-14b__why {
margin-top: 12px;
font-size: 12.5px;
color: rgba(210,225,250,.5);
text-align: center;
min-height: 18px;
}
.shk-14b__why.err {
color: oklch(0.78 0.13 25);
font-weight: 600;
}
.shk-14b__why.ok {
color: var(--ok);
font-weight: 600;
}
@keyframes shk-14b-shake {
15% {
translate: -7px 0;
}
32% {
translate: 6px 0;
}
50% {
translate: -4px 0;
}
68% {
translate: 2px 0;
}
85% {
translate: -1px 0;
}
}
@media (prefers-reduced-motion: reduce) {
.shk-14b__gate.is-nope .shk-14b__btn {
animation: none;
}
}/* No JavaScript — hover lands on the WRAPPER (disabled buttons still render :hover styles but swallow events), which drives both the head-shake and the tooltip. :has(:checked) restyles the button and swaps the head-shake for a nod. (CSS can't flip the real disabled attribute — see variant b.) */
(function(){
var gate=document.getElementById('shk-14b-gate'); if(!gate) return;
var btn=document.getElementById('shk-14b-btn'),why=document.getElementById('shk-14b-why'),
list=document.getElementById('shk-14b-list');
function pending(){ return [].filter.call(list.querySelectorAll('input'),function(c){ return !c.checked; }).length; }
function sync(){
var n=pending();
btn.disabled=n>0;
why.className='shk-14b__why'+(n?'':' ok');
why.textContent=n?n+(n===1?' check':' checks')+' remaining':'All checks green — safe to deploy.';
}
list.addEventListener('change',sync);
gate.addEventListener('animationend',function(){ gate.classList.remove('is-nope'); });
// Disabled <button> elements DO NOT dispatch click events (modern spec —
// Chromium, Firefox, Safari all agree). But pointerdown DOES fire and
// bubble even when the target is disabled — so the wrapper listens on
// pointerdown to catch the "user tried to click a disabled button"
// gesture. When the button becomes enabled, the same wrapper handles
// the enabled path via click on the button itself.
gate.addEventListener('pointerdown',function(e){
if(!btn.disabled) return; // let the button's own click handler run when enabled
if(e.target!==btn && !btn.contains(e.target)) return; // only shake for clicks on the button itself
gate.classList.remove('is-nope');
requestAnimationFrame(function(){ requestAnimationFrame(function(){ gate.classList.add('is-nope'); }); });
why.className='shk-14b__why err';
why.textContent='Blocked: '+pending()+' unchecked item'+(pending()===1?'':'s')+' above.';
});
// Enabled-button path: real click dispatches normally once the button
// is no longer disabled.
btn.addEventListener('click',function(){
why.className='shk-14b__why ok';
why.textContent='Deploying v2.4.1…';
});
sync();
})();/* No JavaScript — hover lands on the WRAPPER (disabled buttons still render :hover styles but swallow events), which drives both the head-shake and the tooltip. :has(:checked) restyles the button and swaps the head-shake for a nod. (CSS can't flip the real disabled attribute — see variant b.) */
(function(){
var gate=document.getElementById('shk-14b-gate'); if(!gate) return;
var btn=document.getElementById('shk-14b-btn'),why=document.getElementById('shk-14b-why'),
list=document.getElementById('shk-14b-list');
function pending(){ return [].filter.call(list.querySelectorAll('input'),function(c){ return !c.checked; }).length; }
function sync(){
var n=pending();
btn.disabled=n>0;
why.className='shk-14b__why'+(n?'':' ok');
why.textContent=n?n+(n===1?' check':' checks')+' remaining':'All checks green — safe to deploy.';
}
list.addEventListener('change',sync);
gate.addEventListener('animationend',function(){ gate.classList.remove('is-nope'); });
// Disabled <button> elements DO NOT dispatch click events (modern spec —
// Chromium, Firefox, Safari all agree). But pointerdown DOES fire and
// bubble even when the target is disabled — so the wrapper listens on
// pointerdown to catch the "user tried to click a disabled button"
// gesture. When the button becomes enabled, the same wrapper handles
// the enabled path via click on the button itself.
gate.addEventListener('pointerdown',function(e){
if(!btn.disabled) return; // let the button's own click handler run when enabled
if(e.target!==btn && !btn.contains(e.target)) return; // only shake for clicks on the button itself
gate.classList.remove('is-nope');
requestAnimationFrame(function(){ requestAnimationFrame(function(){ gate.classList.add('is-nope'); }); });
why.className='shk-14b__why err';
why.textContent='Blocked: '+pending()+' unchecked item'+(pending()===1?'':'s')+' above.';
});
// Enabled-button path: real click dispatches normally once the button
// is no longer disabled.
btn.addEventListener('click',function(){
why.className='shk-14b__why ok';
why.textContent='Deploying v2.4.1…';
});
sync();
})();Here's a working CSS Shake Animation from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Disabled Button Shake CSS
Source: https://codefronts.com/motion/css-shake-animation/disabled-button-shake-css/
The disabled-button problem solved twice: a pure-CSS version where hovering the wrapper makes the dead button shake its head and float its reason ('accept the terms first'), and the full JavaScript pattern that catches clicks on a wrapper — because disabled buttons swallow their own click events — then shakes, explains, and unlocks live when the checklist completes.
## HTML
```html
<div class="shk-14-group">
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@500..800&display=swap" rel="stylesheet">
<section class="shk-14a" aria-label="Disabled button that shakes its head on hover">
<div class="shk-14a__card">
<h2 class="shk-14a__title">Almost there</h2>
<label class="shk-14a__check"><input class="shk-14a__box" id="shk-14a-terms" type="checkbox"><span>I've read the <b>terms of service</b></span></label>
<span class="shk-14a__gate">
<button class="shk-14a__btn" type="button" disabled>Create account</button>
<span class="shk-14a__tip" role="tooltip">Accept the terms first</span>
</span>
<p class="shk-14a__hint" aria-hidden="true">hover the dead button — it shakes “no”; tick the box and CSS :has() wakes it up</p>
</div>
</section>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400..700&display=swap" rel="stylesheet">
<section class="shk-14b" aria-label="Disabled deploy button that shakes and explains when clicked">
<div class="shk-14b__card">
<div class="shk-14b__head"><h2 class="shk-14b__title">Deploy to production</h2><span class="shk-14b__env">v2.4.1</span></div>
<ul class="shk-14b__list" id="shk-14b-list">
<li><label class="shk-14b__item"><input type="checkbox" checked> Tests passing <em>412/412</em></label></li>
<li><label class="shk-14b__item"><input type="checkbox" id="shk-14b-review"> Code review approved</label></li>
<li><label class="shk-14b__item"><input type="checkbox" id="shk-14b-migr"> Migrations dry-run</label></li>
</ul>
<span class="shk-14b__gate" id="shk-14b-gate">
<button class="shk-14b__btn" id="shk-14b-btn" type="button" disabled>Deploy</button>
</span>
<p class="shk-14b__why" id="shk-14b-why" aria-live="polite">2 checks remaining</p>
</div>
</section>
</div>
```
## CSS
```css
.shk-14-group {
display: flex;
flex-wrap: wrap;
align-items: stretch;
width: 100%;
}
.shk-14-group > section {
flex: 1 1 480px;
min-width: min(100%,360px);
}
.shk-14a,
.shk-14a *,
.shk-14a *::before,
.shk-14a *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.shk-14a {
--ink: #1d2025;
width: 100%;
min-height: 100vh;
display: grid;
place-items: center;
padding: 48px 24px;
background: linear-gradient(180deg,#f4f5f6,#e9ebed);
font-family: 'Manrope','Segoe UI',system-ui,sans-serif;
}
.shk-14a__card {
width: min(400px,100%);
padding: 36px;
border-radius: 20px;
background: #fff;
border: 1px solid rgba(29,32,37,.1);
box-shadow: 0 30px 70px -40px rgba(29,32,37,.45);
display: grid;
gap: 18px;
}
.shk-14a__title {
font-size: 23px;
font-weight: 800;
letter-spacing: -.01em;
color: var(--ink);
}
.shk-14a__check {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
color: rgba(29,32,37,.75);
cursor: pointer;
user-select: none;
}
.shk-14a__check b {
color: var(--ink);
}
.shk-14a__box {
width: 19px;
height: 19px;
accent-color: var(--ink);
cursor: pointer;
}
.shk-14a__gate {
position: relative;
display: block;
}
.shk-14a__btn {
width: 100%;
padding: 15px;
font: inherit;
font-size: 15px;
font-weight: 800;
border: 0;
border-radius: 13px;
color: #fff;
background: var(--ink);
cursor: pointer;
transition: opacity .25s,background .25s,scale .12s;
}
.shk-14a__btn:not(:disabled):hover {
background: #31353c;
}
.shk-14a__btn:not(:disabled):active {
scale: .98;
}
.shk-14a__btn:disabled {
opacity: .45;
cursor: not-allowed;
}
.shk-14a__gate:hover .shk-14a__btn:disabled {
animation: shk-14a-headshake .5s ease-in-out;
}
.shk-14a__tip {
position: absolute;
left: 50%;
bottom: calc(100% + 10px);
translate: -50% 4px;
white-space: nowrap;
font-size: 12px;
font-weight: 700;
color: #fff;
background: var(--ink);
padding: 7px 12px;
border-radius: 8px;
opacity: 0;
pointer-events: none;
transition: opacity .2s,translate .2s;
}
.shk-14a__tip::after {
content: "";
position: absolute;
left: 50%;
top: 100%;
translate: -50% 0;
border: 5px solid transparent;
border-top-color: var(--ink);
}
.shk-14a__gate:hover .shk-14a__tip {
opacity: 1;
translate: -50% 0;
}
.shk-14a__card:has(.shk-14a__box:checked) .shk-14a__btn {
opacity: 1;
cursor: pointer;
background: oklch(0.55 0.15 155);
}
.shk-14a__card:has(.shk-14a__box:checked) .shk-14a__gate:hover .shk-14a__btn {
animation: shk-14a-nod .45s cubic-bezier(.34,1.56,.64,1);
}
.shk-14a__card:has(.shk-14a__box:checked) .shk-14a__tip {
display: none;
}
.shk-14a__hint {
font-size: 11.5px;
color: rgba(29,32,37,.45);
}
@keyframes shk-14a-headshake {
0%,
100% {
rotate: 0deg;
}
20% {
rotate: -4deg;
}
45% {
rotate: 3.5deg;
}
70% {
rotate: -2deg;
}
88% {
rotate: 1deg;
}
}
@keyframes shk-14a-nod {
30% {
translate: 0 5px;
}
65% {
translate: 0 -3px;
}
88% {
translate: 0 1px;
}
}
@media (prefers-reduced-motion: reduce) {
.shk-14a__gate:hover .shk-14a__btn:disabled,
.shk-14a__card:has(.shk-14a__box:checked) .shk-14a__gate:hover .shk-14a__btn {
animation: none;
}
}
.shk-14b,
.shk-14b *,
.shk-14b *::before,
.shk-14b *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.shk-14b {
--ok: oklch(0.75 0.16 155);
width: 100%;
min-height: 100vh;
display: grid;
place-items: center;
padding: 48px 24px;
background: linear-gradient(170deg,#12151c,#0b0d12);
font-family: 'Space Grotesk','Segoe UI',system-ui,sans-serif;
}
.shk-14b__card {
width: min(420px,100%);
padding: 30px;
border-radius: 18px;
background: rgba(255,255,255,.045);
border: 1px solid rgba(160,185,220,.15);
box-shadow: 0 40px 90px -50px rgba(0,0,0,.9);
}
.shk-14b__head {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.shk-14b__title {
font-size: 19px;
font-weight: 700;
color: #e9eefa;
letter-spacing: -.01em;
}
.shk-14b__env {
font-size: 12px;
font-weight: 600;
color: rgba(210,225,250,.5);
}
.shk-14b__list {
margin-top: 18px;
list-style: none;
display: grid;
gap: 9px;
}
.shk-14b__item {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
color: rgba(226,234,250,.8);
padding: 11px 13px;
background: rgba(8,11,16,.5);
border: 1px solid rgba(160,185,220,.12);
border-radius: 11px;
cursor: pointer;
}
.shk-14b__item input {
width: 17px;
height: 17px;
accent-color: oklch(0.75 0.16 155);
}
.shk-14b__item em {
margin-left: auto;
font-style: normal;
font-size: 11.5px;
color: rgba(210,225,250,.45);
}
.shk-14b__gate {
display: block;
margin-top: 18px;
}
.shk-14b__btn {
width: 100%;
padding: 15px;
font: inherit;
font-size: 15px;
font-weight: 700;
border: 0;
border-radius: 12px;
color: #0b0d12;
background: var(--ok);
cursor: pointer;
transition: opacity .25s,filter .2s,scale .12s;
}
.shk-14b__btn:disabled {
opacity: .32;
cursor: not-allowed;
}
.shk-14b__btn:not(:disabled):hover {
filter: brightness(1.08);
}
.shk-14b__btn:not(:disabled):active {
scale: .98;
}
.shk-14b__gate.is-nope .shk-14b__btn {
animation: shk-14b-shake .5s cubic-bezier(.36,.07,.19,.97);
}
.shk-14b__why {
margin-top: 12px;
font-size: 12.5px;
color: rgba(210,225,250,.5);
text-align: center;
min-height: 18px;
}
.shk-14b__why.err {
color: oklch(0.78 0.13 25);
font-weight: 600;
}
.shk-14b__why.ok {
color: var(--ok);
font-weight: 600;
}
@keyframes shk-14b-shake {
15% {
translate: -7px 0;
}
32% {
translate: 6px 0;
}
50% {
translate: -4px 0;
}
68% {
translate: 2px 0;
}
85% {
translate: -1px 0;
}
}
@media (prefers-reduced-motion: reduce) {
.shk-14b__gate.is-nope .shk-14b__btn {
animation: none;
}
}
```
## JavaScript
```js
/* No JavaScript — hover lands on the WRAPPER (disabled buttons still render :hover styles but swallow events), which drives both the head-shake and the tooltip. :has(:checked) restyles the button and swaps the head-shake for a nod. (CSS can't flip the real disabled attribute — see variant b.) */
(function(){
var gate=document.getElementById('shk-14b-gate'); if(!gate) return;
var btn=document.getElementById('shk-14b-btn'),why=document.getElementById('shk-14b-why'),
list=document.getElementById('shk-14b-list');
function pending(){ return [].filter.call(list.querySelectorAll('input'),function(c){ return !c.checked; }).length; }
function sync(){
var n=pending();
btn.disabled=n>0;
why.className='shk-14b__why'+(n?'':' ok');
why.textContent=n?n+(n===1?' check':' checks')+' remaining':'All checks green — safe to deploy.';
}
list.addEventListener('change',sync);
gate.addEventListener('animationend',function(){ gate.classList.remove('is-nope'); });
// Disabled <button> elements DO NOT dispatch click events (modern spec —
// Chromium, Firefox, Safari all agree). But pointerdown DOES fire and
// bubble even when the target is disabled — so the wrapper listens on
// pointerdown to catch the "user tried to click a disabled button"
// gesture. When the button becomes enabled, the same wrapper handles
// the enabled path via click on the button itself.
gate.addEventListener('pointerdown',function(e){
if(!btn.disabled) return; // let the button's own click handler run when enabled
if(e.target!==btn && !btn.contains(e.target)) return; // only shake for clicks on the button itself
gate.classList.remove('is-nope');
requestAnimationFrame(function(){ requestAnimationFrame(function(){ gate.classList.add('is-nope'); }); });
why.className='shk-14b__why err';
why.textContent='Blocked: '+pending()+' unchecked item'+(pending()===1?'':'s')+' above.';
});
// Enabled-button path: real click dispatches normally once the button
// is no longer disabled.
btn.addEventListener('click',function(){
why.className='shk-14b__why ok';
why.textContent='Deploying v2.4.1…';
});
sync();
})();
```Here's a working CSS Shake Animation from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Disabled Button Shake CSS
Source: https://codefronts.com/motion/css-shake-animation/disabled-button-shake-css/
The disabled-button problem solved twice: a pure-CSS version where hovering the wrapper makes the dead button shake its head and float its reason ('accept the terms first'), and the full JavaScript pattern that catches clicks on a wrapper — because disabled buttons swallow their own click events — then shakes, explains, and unlocks live when the checklist completes.
## HTML
```html
<div class="shk-14-group">
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@500..800&display=swap" rel="stylesheet">
<section class="shk-14a" aria-label="Disabled button that shakes its head on hover">
<div class="shk-14a__card">
<h2 class="shk-14a__title">Almost there</h2>
<label class="shk-14a__check"><input class="shk-14a__box" id="shk-14a-terms" type="checkbox"><span>I've read the <b>terms of service</b></span></label>
<span class="shk-14a__gate">
<button class="shk-14a__btn" type="button" disabled>Create account</button>
<span class="shk-14a__tip" role="tooltip">Accept the terms first</span>
</span>
<p class="shk-14a__hint" aria-hidden="true">hover the dead button — it shakes “no”; tick the box and CSS :has() wakes it up</p>
</div>
</section>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400..700&display=swap" rel="stylesheet">
<section class="shk-14b" aria-label="Disabled deploy button that shakes and explains when clicked">
<div class="shk-14b__card">
<div class="shk-14b__head"><h2 class="shk-14b__title">Deploy to production</h2><span class="shk-14b__env">v2.4.1</span></div>
<ul class="shk-14b__list" id="shk-14b-list">
<li><label class="shk-14b__item"><input type="checkbox" checked> Tests passing <em>412/412</em></label></li>
<li><label class="shk-14b__item"><input type="checkbox" id="shk-14b-review"> Code review approved</label></li>
<li><label class="shk-14b__item"><input type="checkbox" id="shk-14b-migr"> Migrations dry-run</label></li>
</ul>
<span class="shk-14b__gate" id="shk-14b-gate">
<button class="shk-14b__btn" id="shk-14b-btn" type="button" disabled>Deploy</button>
</span>
<p class="shk-14b__why" id="shk-14b-why" aria-live="polite">2 checks remaining</p>
</div>
</section>
</div>
```
## CSS
```css
.shk-14-group {
display: flex;
flex-wrap: wrap;
align-items: stretch;
width: 100%;
}
.shk-14-group > section {
flex: 1 1 480px;
min-width: min(100%,360px);
}
.shk-14a,
.shk-14a *,
.shk-14a *::before,
.shk-14a *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.shk-14a {
--ink: #1d2025;
width: 100%;
min-height: 100vh;
display: grid;
place-items: center;
padding: 48px 24px;
background: linear-gradient(180deg,#f4f5f6,#e9ebed);
font-family: 'Manrope','Segoe UI',system-ui,sans-serif;
}
.shk-14a__card {
width: min(400px,100%);
padding: 36px;
border-radius: 20px;
background: #fff;
border: 1px solid rgba(29,32,37,.1);
box-shadow: 0 30px 70px -40px rgba(29,32,37,.45);
display: grid;
gap: 18px;
}
.shk-14a__title {
font-size: 23px;
font-weight: 800;
letter-spacing: -.01em;
color: var(--ink);
}
.shk-14a__check {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
color: rgba(29,32,37,.75);
cursor: pointer;
user-select: none;
}
.shk-14a__check b {
color: var(--ink);
}
.shk-14a__box {
width: 19px;
height: 19px;
accent-color: var(--ink);
cursor: pointer;
}
.shk-14a__gate {
position: relative;
display: block;
}
.shk-14a__btn {
width: 100%;
padding: 15px;
font: inherit;
font-size: 15px;
font-weight: 800;
border: 0;
border-radius: 13px;
color: #fff;
background: var(--ink);
cursor: pointer;
transition: opacity .25s,background .25s,scale .12s;
}
.shk-14a__btn:not(:disabled):hover {
background: #31353c;
}
.shk-14a__btn:not(:disabled):active {
scale: .98;
}
.shk-14a__btn:disabled {
opacity: .45;
cursor: not-allowed;
}
.shk-14a__gate:hover .shk-14a__btn:disabled {
animation: shk-14a-headshake .5s ease-in-out;
}
.shk-14a__tip {
position: absolute;
left: 50%;
bottom: calc(100% + 10px);
translate: -50% 4px;
white-space: nowrap;
font-size: 12px;
font-weight: 700;
color: #fff;
background: var(--ink);
padding: 7px 12px;
border-radius: 8px;
opacity: 0;
pointer-events: none;
transition: opacity .2s,translate .2s;
}
.shk-14a__tip::after {
content: "";
position: absolute;
left: 50%;
top: 100%;
translate: -50% 0;
border: 5px solid transparent;
border-top-color: var(--ink);
}
.shk-14a__gate:hover .shk-14a__tip {
opacity: 1;
translate: -50% 0;
}
.shk-14a__card:has(.shk-14a__box:checked) .shk-14a__btn {
opacity: 1;
cursor: pointer;
background: oklch(0.55 0.15 155);
}
.shk-14a__card:has(.shk-14a__box:checked) .shk-14a__gate:hover .shk-14a__btn {
animation: shk-14a-nod .45s cubic-bezier(.34,1.56,.64,1);
}
.shk-14a__card:has(.shk-14a__box:checked) .shk-14a__tip {
display: none;
}
.shk-14a__hint {
font-size: 11.5px;
color: rgba(29,32,37,.45);
}
@keyframes shk-14a-headshake {
0%,
100% {
rotate: 0deg;
}
20% {
rotate: -4deg;
}
45% {
rotate: 3.5deg;
}
70% {
rotate: -2deg;
}
88% {
rotate: 1deg;
}
}
@keyframes shk-14a-nod {
30% {
translate: 0 5px;
}
65% {
translate: 0 -3px;
}
88% {
translate: 0 1px;
}
}
@media (prefers-reduced-motion: reduce) {
.shk-14a__gate:hover .shk-14a__btn:disabled,
.shk-14a__card:has(.shk-14a__box:checked) .shk-14a__gate:hover .shk-14a__btn {
animation: none;
}
}
.shk-14b,
.shk-14b *,
.shk-14b *::before,
.shk-14b *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.shk-14b {
--ok: oklch(0.75 0.16 155);
width: 100%;
min-height: 100vh;
display: grid;
place-items: center;
padding: 48px 24px;
background: linear-gradient(170deg,#12151c,#0b0d12);
font-family: 'Space Grotesk','Segoe UI',system-ui,sans-serif;
}
.shk-14b__card {
width: min(420px,100%);
padding: 30px;
border-radius: 18px;
background: rgba(255,255,255,.045);
border: 1px solid rgba(160,185,220,.15);
box-shadow: 0 40px 90px -50px rgba(0,0,0,.9);
}
.shk-14b__head {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.shk-14b__title {
font-size: 19px;
font-weight: 700;
color: #e9eefa;
letter-spacing: -.01em;
}
.shk-14b__env {
font-size: 12px;
font-weight: 600;
color: rgba(210,225,250,.5);
}
.shk-14b__list {
margin-top: 18px;
list-style: none;
display: grid;
gap: 9px;
}
.shk-14b__item {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
color: rgba(226,234,250,.8);
padding: 11px 13px;
background: rgba(8,11,16,.5);
border: 1px solid rgba(160,185,220,.12);
border-radius: 11px;
cursor: pointer;
}
.shk-14b__item input {
width: 17px;
height: 17px;
accent-color: oklch(0.75 0.16 155);
}
.shk-14b__item em {
margin-left: auto;
font-style: normal;
font-size: 11.5px;
color: rgba(210,225,250,.45);
}
.shk-14b__gate {
display: block;
margin-top: 18px;
}
.shk-14b__btn {
width: 100%;
padding: 15px;
font: inherit;
font-size: 15px;
font-weight: 700;
border: 0;
border-radius: 12px;
color: #0b0d12;
background: var(--ok);
cursor: pointer;
transition: opacity .25s,filter .2s,scale .12s;
}
.shk-14b__btn:disabled {
opacity: .32;
cursor: not-allowed;
}
.shk-14b__btn:not(:disabled):hover {
filter: brightness(1.08);
}
.shk-14b__btn:not(:disabled):active {
scale: .98;
}
.shk-14b__gate.is-nope .shk-14b__btn {
animation: shk-14b-shake .5s cubic-bezier(.36,.07,.19,.97);
}
.shk-14b__why {
margin-top: 12px;
font-size: 12.5px;
color: rgba(210,225,250,.5);
text-align: center;
min-height: 18px;
}
.shk-14b__why.err {
color: oklch(0.78 0.13 25);
font-weight: 600;
}
.shk-14b__why.ok {
color: var(--ok);
font-weight: 600;
}
@keyframes shk-14b-shake {
15% {
translate: -7px 0;
}
32% {
translate: 6px 0;
}
50% {
translate: -4px 0;
}
68% {
translate: 2px 0;
}
85% {
translate: -1px 0;
}
}
@media (prefers-reduced-motion: reduce) {
.shk-14b__gate.is-nope .shk-14b__btn {
animation: none;
}
}
```
## JavaScript
```js
/* No JavaScript — hover lands on the WRAPPER (disabled buttons still render :hover styles but swallow events), which drives both the head-shake and the tooltip. :has(:checked) restyles the button and swaps the head-shake for a nod. (CSS can't flip the real disabled attribute — see variant b.) */
(function(){
var gate=document.getElementById('shk-14b-gate'); if(!gate) return;
var btn=document.getElementById('shk-14b-btn'),why=document.getElementById('shk-14b-why'),
list=document.getElementById('shk-14b-list');
function pending(){ return [].filter.call(list.querySelectorAll('input'),function(c){ return !c.checked; }).length; }
function sync(){
var n=pending();
btn.disabled=n>0;
why.className='shk-14b__why'+(n?'':' ok');
why.textContent=n?n+(n===1?' check':' checks')+' remaining':'All checks green — safe to deploy.';
}
list.addEventListener('change',sync);
gate.addEventListener('animationend',function(){ gate.classList.remove('is-nope'); });
// Disabled <button> elements DO NOT dispatch click events (modern spec —
// Chromium, Firefox, Safari all agree). But pointerdown DOES fire and
// bubble even when the target is disabled — so the wrapper listens on
// pointerdown to catch the "user tried to click a disabled button"
// gesture. When the button becomes enabled, the same wrapper handles
// the enabled path via click on the button itself.
gate.addEventListener('pointerdown',function(e){
if(!btn.disabled) return; // let the button's own click handler run when enabled
if(e.target!==btn && !btn.contains(e.target)) return; // only shake for clicks on the button itself
gate.classList.remove('is-nope');
requestAnimationFrame(function(){ requestAnimationFrame(function(){ gate.classList.add('is-nope'); }); });
why.className='shk-14b__why err';
why.textContent='Blocked: '+pending()+' unchecked item'+(pending()===1?'':'s')+' above.';
});
// Enabled-button path: real click dispatches normally once the button
// is no longer disabled.
btn.addEventListener('click',function(){
why.className='shk-14b__why ok';
why.textContent='Deploying v2.4.1…';
});
sync();
})();
```How this works
The gotcha this topic exists for: button[disabled] does not fire click events. Listeners on the button never run, so put the listener (and the hover style) on a wrapper:
<span class="gate">
<button disabled>Continue</button>
</span>
gate.addEventListener('click', () => {
if (btn.disabled) { gate.classList.add('is-nope'); }
});.gate:hover [disabled]{animation:headshake .5s ease-in-out}The motion vocabulary differs from an error shake on purpose: a slow rotational head-shake (±5°, eased) says 'no, not yet' — gentler than the sharp translate rattle of a rejection. Both variants keep the reason visible: motion attracts the eye, the caption does the explaining.
Make it yours
- Prefer aria-disabled=true + real click handling when you need focusability and screen-reader discovery; use disabled when the action truly can't run.
- List the unmet requirements in the tooltip/caption — 'complete 2 more steps' beats a mute shake.
- On unlock, play a single positive nod (Y-bounce) so the state flip is felt as well as seen.
- Keep the head-shake ≤ ±5° and ≥ .45s: slower and rounder than error shakes, it's advice not alarm.
Gotchas — read before shipping
- Disabled buttons fire no click, no focus, no keyboard events — every 'why is my listener dead' bug traces here.
- pointer-events:none on the button would ALSO kill the wrapper's hover-over-button area; leave pointer events on and let the wrapper catch what bubbles… nothing bubbles — the wrapper catches clicks on ITSELF where the button ignores them.
- Don't shake on every hover AND every click — pick hover for discovery, click for insistence, not both at full volume.
- Remember :has(:checked) restyles the button, but only JS can flip the real disabled attribute.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 15.4+ | 121+ | 111+ |
Floor set by :has(), oklch() as this demo is written. The core technique itself goes back further — Chrome 105+.
The wrapper-catch JS works in every browser ever. The live unlock styling uses :has(:checked) (Chrome 105 / Safari 15.4 / Firefox 121); the JS variant flips the real disabled attribute regardless.