Door hanger
import { useState } from 'react';
const css = `
.cb-door-hanger { --card: light-dark(#f3f4f7, #e5e6e9); --card-ink: light-dark(#373a40, #121417); --night: light-dark(#373a40, #232529); --night-ink: light-dark(#fbfcfd, #f1f2f3); --brass: light-dark(#5f626a, #7d8087); --brass-hi: light-dark(#dbdde1, #c6c8cd); flex: none; position: relative; display: block; width: 240px; height: 226px; color: light-dark(#42454c, #e5e6e9); cursor: pointer; -webkit-user-select: none; user-select: none; }
.cb-door-hanger__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
.cb-door-hanger__art { position: absolute; inset: 0; display: block; border-radius: 12px; }
.cb-door-hanger__input:focus-visible + .cb-door-hanger__art { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
.cb-door-hanger__plate { position: absolute; left: 50%; top: 2px; width: 26px; height: 74px; margin-left: -13px; border-radius: 13px; background: linear-gradient(90deg, var(--brass), var(--brass-hi) 42%, var(--brass)); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); }
.cb-door-hanger__swing { position: absolute; left: 50%; top: 2px; width: 112px; height: 196px; margin-left: -56px; transform-origin: 50% 27px; filter: drop-shadow(0 5px 6px rgba(0, 0, 0, 0.3)); }
.cb-door-hanger:hover .cb-door-hanger__swing { animation: cb-door-hanger-sway 2.2s ease-in-out infinite; }
@keyframes cb-door-hanger-sway {
0%, 100% { transform: rotate(-2deg); }
50% { transform: rotate(2.4deg); }
}
.cb-door-hanger[data-swing='a'] .cb-door-hanger__swing { animation: cb-door-hanger-swing-a 1.25s ease-out; }
.cb-door-hanger[data-swing='b'] .cb-door-hanger__swing { animation: cb-door-hanger-swing-b 1.25s ease-out; }
@keyframes cb-door-hanger-swing-a {
0% { transform: none; }
16% { transform: rotate(13deg); }
38% { transform: rotate(-9deg); }
58% { transform: rotate(5deg); }
78% { transform: rotate(-2deg); }
100% { transform: none; }
}
@keyframes cb-door-hanger-swing-b {
0% { transform: none; }
16% { transform: rotate(-13deg); }
38% { transform: rotate(9deg); }
58% { transform: rotate(-5deg); }
78% { transform: rotate(2deg); }
100% { transform: none; }
}
.cb-door-hanger__flip { position: absolute; inset: 0; transform: perspective(620px) rotateY(0deg); transform-style: preserve-3d; transition: transform 0.72s cubic-bezier(0.3, 1.25, 0.5, 1); }
.cb-door-hanger__input:checked + .cb-door-hanger__art .cb-door-hanger__flip { transform: perspective(620px) rotateY(180deg); }
.cb-door-hanger__face { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; box-sizing: border-box; padding: 0 10px 14px; border-radius: 10px; backface-visibility: hidden; text-align: center; -webkit-mask: radial-gradient(circle 15px at 50% 27px, transparent 96%, #000 100%); mask: radial-gradient(circle 15px at 50% 27px, transparent 96%, #000 100%); }
.cb-door-hanger__face--off { background: var(--card); color: var(--card-ink); box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--card-ink) 18%, transparent); }
.cb-door-hanger__face--on { background: var(--night); color: var(--night-ink); transform: rotateY(180deg); }
.cb-door-hanger__icon { display: block; width: 44px; height: 44px; margin-bottom: 8px; overflow: visible; }
.cb-door-hanger__big { font: italic 700 19px/1.08 Georgia, 'Times New Roman', serif; letter-spacing: -0.01em; }
.cb-door-hanger__small { margin-top: 7px; font: 500 8.5px/1.2 system-ui, sans-serif; letter-spacing: 0.1em; text-transform: uppercase; }
.cb-door-hanger__rule { width: 34px; height: 1px; margin: 7px 0 0; background: currentColor; opacity: 0.6; }
.cb-door-hanger__knob { position: absolute; left: 50%; top: 14px; width: 26px; height: 26px; margin-left: -13px; border-radius: 50%; background: radial-gradient(circle at 36% 30%, var(--brass-hi), var(--brass) 72%); box-shadow: 0 3px 5px rgba(0, 0, 0, 0.42); }
.cb-door-hanger__label { position: absolute; left: 0; right: 0; bottom: 2px; font-size: 13.5px; font-weight: 500; text-align: center; }
@media (prefers-reduced-motion: reduce) {
.cb-door-hanger__flip { transition: none; }
.cb-door-hanger:hover .cb-door-hanger__swing, .cb-door-hanger[data-swing] .cb-door-hanger__swing { animation: none; }
}
`;
export function DoorHangerCheckbox() {
// The two swings are identical keyframes, swapped so the swing restarts on every change.
const [swing, setSwing] = useState<'a' | 'b' | undefined>();
return (
<>
<style>
{css}
</style>
<label className="cb-door-hanger" data-swing={swing}>
<input className="cb-door-hanger__input" type="checkbox" onChange={() => setSwing((s) => (s === 'a' ? 'b' : 'a'))} />
<span className="cb-door-hanger__art" aria-hidden="true">
<span className="cb-door-hanger__plate" />
<span className="cb-door-hanger__swing">
<span className="cb-door-hanger__flip">
<span className="cb-door-hanger__face cb-door-hanger__face--off">
<svg className="cb-door-hanger__icon" viewBox="0 0 44 44" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 5C24 12 28 16 35 18C28 20 24 24 22 31C20 24 16 20 9 18C16 16 20 12 22 5Z" fill="currentColor" stroke="none" />
<path d="M35 29C36 32 37 33 40 34C37 35 36 36 35 39C34 36 33 35 30 34C33 33 34 32 35 29Z" fill="currentColor" stroke="none" />
</svg>
<span className="cb-door-hanger__big">Please make up my room</span>
<span className="cb-door-hanger__rule" />
<span className="cb-door-hanger__small">Room 412</span>
</span>
<span className="cb-door-hanger__face cb-door-hanger__face--on">
<svg className="cb-door-hanger__icon" viewBox="0 0 44 44" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M31 28A15 15 0 0 1 17 8A15 15 0 1 0 36 26A15 15 0 0 1 31 28Z" fill="currentColor" stroke="none" />
<path d="M30 6H37L30 14H37" />
</svg>
<span className="cb-door-hanger__big">Do not disturb</span>
<span className="cb-door-hanger__rule" />
<span className="cb-door-hanger__small">Room 412</span>
</span>
</span>
</span>
<span className="cb-door-hanger__knob" />
</span>
<span className="cb-door-hanger__label">Do not disturb</span>
</label>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCbDoorHanger(el) on the mounted element instead.
<label class="cb-door-hanger">
<input class="cb-door-hanger__input" type="checkbox"/>
<span class="cb-door-hanger__art" aria-hidden="true">
<span class="cb-door-hanger__plate"></span>
<span class="cb-door-hanger__swing">
<span class="cb-door-hanger__flip">
<span class="cb-door-hanger__face cb-door-hanger__face--off">
<svg class="cb-door-hanger__icon" viewBox="0 0 44 44" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 5C24 12 28 16 35 18C28 20 24 24 22 31C20 24 16 20 9 18C16 16 20 12 22 5Z" fill="currentColor" stroke="none"></path>
<path d="M35 29C36 32 37 33 40 34C37 35 36 36 35 39C34 36 33 35 30 34C33 33 34 32 35 29Z" fill="currentColor" stroke="none"></path>
</svg>
<span class="cb-door-hanger__big">Please make up my room</span>
<span class="cb-door-hanger__rule"></span>
<span class="cb-door-hanger__small">Room 412</span>
</span>
<span class="cb-door-hanger__face cb-door-hanger__face--on">
<svg class="cb-door-hanger__icon" viewBox="0 0 44 44" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M31 28A15 15 0 0 1 17 8A15 15 0 1 0 36 26A15 15 0 0 1 31 28Z" fill="currentColor" stroke="none"></path>
<path d="M30 6H37L30 14H37"></path>
</svg>
<span class="cb-door-hanger__big">Do not disturb</span>
<span class="cb-door-hanger__rule"></span>
<span class="cb-door-hanger__small">Room 412</span>
</span>
</span>
</span>
<span class="cb-door-hanger__knob"></span>
</span>
<span class="cb-door-hanger__label">Do not disturb</span>
</label>.cb-door-hanger { --card: light-dark(#f3f4f7, #e5e6e9); --card-ink: light-dark(#373a40, #121417); --night: light-dark(#373a40, #232529); --night-ink: light-dark(#fbfcfd, #f1f2f3); --brass: light-dark(#5f626a, #7d8087); --brass-hi: light-dark(#dbdde1, #c6c8cd); flex: none; position: relative; display: block; width: 240px; height: 226px; color: light-dark(#42454c, #e5e6e9); cursor: pointer; -webkit-user-select: none; user-select: none; }
.cb-door-hanger__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
.cb-door-hanger__art { position: absolute; inset: 0; display: block; border-radius: 12px; }
.cb-door-hanger__input:focus-visible + .cb-door-hanger__art { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
.cb-door-hanger__plate { position: absolute; left: 50%; top: 2px; width: 26px; height: 74px; margin-left: -13px; border-radius: 13px; background: linear-gradient(90deg, var(--brass), var(--brass-hi) 42%, var(--brass)); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); }
.cb-door-hanger__swing { position: absolute; left: 50%; top: 2px; width: 112px; height: 196px; margin-left: -56px; transform-origin: 50% 27px; filter: drop-shadow(0 5px 6px rgba(0, 0, 0, 0.3)); }
.cb-door-hanger:hover .cb-door-hanger__swing { animation: cb-door-hanger-sway 2.2s ease-in-out infinite; }
@keyframes cb-door-hanger-sway {
0%, 100% { transform: rotate(-2deg); }
50% { transform: rotate(2.4deg); }
}
.cb-door-hanger[data-swing='a'] .cb-door-hanger__swing { animation: cb-door-hanger-swing-a 1.25s ease-out; }
.cb-door-hanger[data-swing='b'] .cb-door-hanger__swing { animation: cb-door-hanger-swing-b 1.25s ease-out; }
@keyframes cb-door-hanger-swing-a {
0% { transform: none; }
16% { transform: rotate(13deg); }
38% { transform: rotate(-9deg); }
58% { transform: rotate(5deg); }
78% { transform: rotate(-2deg); }
100% { transform: none; }
}
@keyframes cb-door-hanger-swing-b {
0% { transform: none; }
16% { transform: rotate(-13deg); }
38% { transform: rotate(9deg); }
58% { transform: rotate(-5deg); }
78% { transform: rotate(2deg); }
100% { transform: none; }
}
.cb-door-hanger__flip { position: absolute; inset: 0; transform: perspective(620px) rotateY(0deg); transform-style: preserve-3d; transition: transform 0.72s cubic-bezier(0.3, 1.25, 0.5, 1); }
.cb-door-hanger__input:checked + .cb-door-hanger__art .cb-door-hanger__flip { transform: perspective(620px) rotateY(180deg); }
.cb-door-hanger__face { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; box-sizing: border-box; padding: 0 10px 14px; border-radius: 10px; backface-visibility: hidden; text-align: center; -webkit-mask: radial-gradient(circle 15px at 50% 27px, transparent 96%, #000 100%); mask: radial-gradient(circle 15px at 50% 27px, transparent 96%, #000 100%); }
.cb-door-hanger__face--off { background: var(--card); color: var(--card-ink); box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--card-ink) 18%, transparent); }
.cb-door-hanger__face--on { background: var(--night); color: var(--night-ink); transform: rotateY(180deg); }
.cb-door-hanger__icon { display: block; width: 44px; height: 44px; margin-bottom: 8px; overflow: visible; }
.cb-door-hanger__big { font: italic 700 19px/1.08 Georgia, 'Times New Roman', serif; letter-spacing: -0.01em; }
.cb-door-hanger__small { margin-top: 7px; font: 500 8.5px/1.2 system-ui, sans-serif; letter-spacing: 0.1em; text-transform: uppercase; }
.cb-door-hanger__rule { width: 34px; height: 1px; margin: 7px 0 0; background: currentColor; opacity: 0.6; }
.cb-door-hanger__knob { position: absolute; left: 50%; top: 14px; width: 26px; height: 26px; margin-left: -13px; border-radius: 50%; background: radial-gradient(circle at 36% 30%, var(--brass-hi), var(--brass) 72%); box-shadow: 0 3px 5px rgba(0, 0, 0, 0.42); }
.cb-door-hanger__label { position: absolute; left: 0; right: 0; bottom: 2px; font-size: 13.5px; font-weight: 500; text-align: center; }
@media (prefers-reduced-motion: reduce) {
.cb-door-hanger__flip { transition: none; }
.cb-door-hanger:hover .cb-door-hanger__swing, .cb-door-hanger[data-swing] .cb-door-hanger__swing { animation: none; }
}function initCbDoorHanger(root) {
var input = root.querySelector('.cb-door-hanger__input');
// The two swings are identical keyframes, swapped so the swing restarts on every change.
function onChange() {
root.setAttribute('data-swing', root.getAttribute('data-swing') === 'a' ? 'b' : 'a');
}
input.addEventListener('change', onChange);
return function () {
input.removeEventListener('change', onChange);
};
}
document.querySelectorAll('.cb-door-hanger').forEach(initCbDoorHanger);Goal: Create a "Door hanger" checkbox.
Visual behavior: A hotel door hanger on the knob. It sways as you come near; tick it and it swings and flips over from Please make up my room to Do not disturb.
Technique:
- Two faces with backface-visibility hidden sit on one flipper, which turns 180 degrees with a slight overshoot. The perspective is part of its transform, and the drop shadow sits on the wrapper around it: a filter on the flipper itself would flatten the 3D and show the front mirrored.
- The wrapper also swings from the knob. Two identical swing keyframes are swapped on every change, so the swing restarts each time. A mask cuts the hole for the knob.
Accessibility: A native checkbox inside a <label>, so "Do not disturb" is its name. The hanger is aria-hidden and keyboard focus outlines it. Under prefers-reduced-motion it flips at once and does not sway.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Door hanger" checkbox.
Visual behavior: A hotel door hanger on the knob. It sways as you come near; tick it and it swings and flips over from Please make up my room to Do not disturb.
Technique:
- Two faces with backface-visibility hidden sit on one flipper, which turns 180 degrees with a slight overshoot. The perspective is part of its transform, and the drop shadow sits on the wrapper around it: a filter on the flipper itself would flatten the 3D and show the front mirrored.
- The wrapper also swings from the knob. Two identical swing keyframes are swapped on every change, so the swing restarts each time. A mask cuts the hole for the knob.
Accessibility: A native checkbox inside a <label>, so "Do not disturb" is its name. The hanger is aria-hidden and keyboard focus outlines it. Under prefers-reduced-motion it flips at once and does not sway.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A hotel door hanger on the knob. It sways as you come near; tick it and it swings and flips over from Please make up my room to Do not disturb.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, hover, state change, 3d, keyframe
- Techniques
- 3d transform, perspective, preserve 3d, keyframes, css transition
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A native checkbox inside a <label>, so "Do not disturb" is its name. The hanger is aria-hidden and keyboard focus outlines it. Under prefers-reduced-motion it flips at once and does not sway.