Door hanger

No dependencies
Code format
React + Tailwind
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>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/checkbox-door-hanger
Source on GitHub

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.

More checkboxes

See all
Hatch
Icing
Fortune ball
Peg