# Hotel Do Not Disturb Door Hanger Card That Swings on a Brass Knob and Flips to Make up My Room

> Door hanger card that swings from a brass knob as a pendulum, lifts clear when pressed and flips in 3D to its other message. Free React and HTML to copy.

A shadcn/ui block from the [@weinc](https://we.inc/components?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/weinc/hotel-do-not-disturb-door-hanger-card-that-swings-on-a-brass-knob-and-flips-to-make-up-my-room).

- Page, with a live preview: https://shadcn.exchange/r/weinc/hotel-do-not-disturb-door-hanger-card-that-swings-on-a-brass-knob-and-flips-to-make-up-my-room
- Screenshot: https://shadcn.exchange/shot/weinc/hotel-do-not-disturb-door-hanger-card-that-swings-on-a-brass-knob-and-flips-to-make-up-my-room/light.png
- Registry item: https://we.inc/r/hotel-do-not-disturb-door-hanger-card-that-swings-on-a-brass-knob-and-flips-to-make-up-my-room.json
- Listed: 2026-10-28, source last changed 2026-10-28

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/weinc/hotel-do-not-disturb-door-hanger-card-that-swings-on-a-brass-knob-and-flips-to-make-up-my-room.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `framer-motion`

## Files

### `components/we-inc/hotel-do-not-disturb-door-hanger-card-that-swings-on-a-brass-knob-and-flips-to-make-up-my-room.tsx`

```tsx
import { useCallback, useEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import { animate, motion, useMotionValue, useReducedMotion, useTransform } from "framer-motion";

/** What is printed on the two sides of the card. */
const hotel = { name: "The Aldermoor", room: "Room 214" };
const sides = {
  quiet: { lines: ["Do not", "disturb"], note: "Sleeping in. Thank you for the quiet.", said: "Do not disturb is showing" },
  service: { lines: ["Please", "make up", "my room"], note: "Out for the day. Fresh towels welcome.", said: "Please make up my room is showing" },
};

// Design pixels, measured from the middle of the door knob.
const CW = 178; // card
const CH = 404;
const HOLE = 38; // radius of the hole in the card
const HOLE_Y = 64; // its centre, down from the top of the card
const NECK = 15; // radius of the knob's neck, where the card rests
const HEAD = 31; // radius of the knob itself, small enough to pass through the hole
const ROSE = 47; // the plate behind it
const DROP = HOLE - NECK; // how far the card must be lifted to clear the knob
const PIVOT_Y = HOLE_Y - DROP;
const KNOB_TOP = 176; // knob centre, down from the top of the scene
const SCENE_H = 650;
const GRAVITY = 30; // swing stiffness, per second squared
const DRAG = 1.05; // how fast the swing dies away
const OXBLOOD = "#7a1c22";
const CREAM = "#efe6d1";
const GREEN = "#1d4a3c";

const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const outline = `M 30 0 H ${CW - 30} A 30 30 0 0 1 ${CW} 30 V ${CH - 14} A 14 14 0 0 1 ${CW - 14} ${CH} H 14 A 14 14 0 0 1 0 ${CH - 14} V 30 A 30 30 0 0 1 30 0 Z M ${CW / 2 - HOLE} ${HOLE_Y} a ${HOLE} ${HOLE} 0 1 0 ${HOLE * 2} 0 a ${HOLE} ${HOLE} 0 1 0 ${-HOLE * 2} 0 Z`;
const cut = `path(evenodd, '${outline}')`;

function Face({ which }: { which: "quiet" | "service" }) {
  const quiet = which === "quiet";
  const s = sides[which];
  const ink = quiet ? CREAM : GREEN;
  return (
    <div className="absolute inset-0" style={{ filter: "drop-shadow(1px 1.6px 0 rgba(40,25,10,0.5))", backfaceVisibility: "hidden", WebkitBackfaceVisibility: "hidden", transform: quiet ? undefined : "rotateY(180deg)" }}>
      <div className="absolute inset-0" style={{ clipPath: cut, background: quiet ? OXBLOOD : CREAM, color: ink }}>
        {/* keyline */}
        <div aria-hidden="true" className="absolute rounded-[7px]" style={{ left: 10, right: 10, top: HOLE_Y + HOLE + 16, bottom: 10, border: `1px solid ${ink}`, opacity: 0.75 }} />
        <div aria-hidden="true" className="absolute rounded-[5px]" style={{ left: 13.5, right: 13.5, top: HOLE_Y + HOLE + 19.5, bottom: 13.5, border: `0.5px solid ${ink}`, opacity: 0.6 }} />

        <div className="absolute inset-x-0 text-center" style={{ top: HOLE_Y + HOLE + 40 }}>
          <svg viewBox="0 0 40 40" aria-hidden="true" className="mx-auto h-[34px] w-[34px]" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
            {quiet ? (
              <>
                <path d="M27.5 24.5A12 12 0 0 1 15.2 8.3 12 12 0 1 0 31.7 24a12.3 12.3 0 0 1-4.2.5z" fill="currentColor" fillOpacity="0.18" />
                <path d="M26 7.5h5l-5 6h5M33 15.5h3.5l-3.5 4.2h3.5" />
              </>
            ) : (
              <>
                <path d="M7 28.5a13 13 0 0 1 26 0z" fill="currentColor" fillOpacity="0.14" />
                <path d="M4.5 32h31M20 15.5v-3M17 12.5h6M11.5 26a8.6 8.6 0 0 1 5-6.3" />
              </>
            )}
          </svg>
          <p className="m-0 mt-[16px] font-serif italic leading-[0.92] tracking-[-0.01em]" style={{ fontSize: quiet ? 39 : 33 }}>
            {s.lines.map((l) => (
              <span key={l} className="block">
                {l}
              </span>
            ))}
          </p>
          <span aria-hidden="true" className="mx-auto mt-[17px] block h-px w-[34px]" style={{ background: ink, opacity: 0.7 }} />
          <p className="mx-auto mb-0 mt-[15px] w-[124px] font-serif text-[11.5px] leading-[1.35]" style={{ opacity: 0.92 }}>
            {s.note}
          </p>
        </div>

        <div className="absolute inset-x-0 bottom-[25px] text-center font-mono font-semibold uppercase leading-none">
          <span className="block text-[9px] tracking-[0.3em]">{hotel.name}</span>
          <span className="mt-[7px] block text-[7.5px] tracking-[0.34em]" style={{ opacity: 0.8 }}>
            {hotel.room}
          </span>
        </div>

        {/* coated board: fibre under the varnish, then a sheen and a soft roll at the edges */}
        <svg aria-hidden="true" className="absolute inset-0 h-full w-full mix-blend-multiply" style={{ opacity: quiet ? 0.55 : 0.4 }}>
          <rect width="100%" height="100%" filter="url(#door-hanger-flip-fibre)" />
        </svg>
        <div aria-hidden="true" className="absolute inset-0" style={{ background: "linear-gradient(118deg, rgba(255,255,255,0.3) 0%, rgba(255,255,255,0.06) 22%, rgba(255,255,255,0) 40%, rgba(0,0,0,0) 66%, rgba(0,0,0,0.2) 100%)" }} />
        <div aria-hidden="true" className="absolute inset-0" style={{ borderRadius: "30px 30px 14px 14px", boxShadow: "inset 0 1.5px 0 rgba(255,255,255,0.45), inset 1.5px 0 0 rgba(255,255,255,0.22), inset -1px -1.5px 1px rgba(0,0,0,0.3)" }} />
        {/* the cut edge of the hole */}
        <div aria-hidden="true" className="absolute rounded-full" style={{ left: CW / 2 - HOLE - 2.5, top: HOLE_Y - HOLE - 2.5, width: HOLE * 2 + 5, height: HOLE * 2 + 5, border: "2.5px solid rgba(0,0,0,0)", borderTopColor: "rgba(0,0,0,0.3)", borderLeftColor: "rgba(0,0,0,0.2)", borderBottomColor: "rgba(255,255,255,0.4)", borderRightColor: "rgba(255,255,255,0.28)" }} />
      </div>
    </div>
  );
}

export default function DoorHangerFlipCard() {
  const reduce = !!useReducedMotion();
  const rootRef = useRef<HTMLElement>(null);
  const stageRef = useRef<HTMLDivElement>(null);
  const swingRef = useRef<HTMLButtonElement>(null);
  const shadeRef = useRef<HTMLDivElement>(null);
  const live = useRef({ th: 0.16, w: 0, scale: 1, drag: false, held: false, aim: 0, grab: 0, moved: 0, x: 0, y: 0, touched: false, paused: false, busy: false, gust: 2.6, clock: 0 });
  const [quiet, setQuiet] = useState(false);
  const [above, setAbove] = useState(false);
  const [said, setSaid] = useState("");

  const flip = useMotionValue(180); // 0 shows "do not disturb", 180 the other side
  const twist = useMotionValue(0);
  const hook = useMotionValue(0);
  const lift = useMotionValue(0);
  const turn = useTransform([flip, twist], (v) => (v[0] as number) + (v[1] as number));
  const grow = useTransform(lift, (v) => 1 + v * 0.1);
  const shadeX = useTransform(lift, (v) => 11 + v * 26);
  const shadeY = useTransform(lift, (v) => 15 + v * 34);
  const shadeW = useTransform([flip, twist, lift], (v) => Math.max(0.05, Math.abs(Math.cos((((v[0] as number) + (v[1] as number)) * Math.PI) / 180))) * (1 + (v[2] as number) * 0.12));
  const shadeA = useTransform(lift, (v) => 0.42 - v * 0.17);
  const shadeNear = useTransform(lift, (v) => 0.5 * (1 - v));
  const shadeBlur = useTransform(lift, (v) => `blur(${(8 + v * 12).toFixed(1)}px)`);

  const turnOver = useCallback(
    (to?: boolean) => {
      const s = live.current;
      if (s.busy) return;
      const next = to ?? flip.get() > 90;
      setQuiet(next);
      const end = next ? 0 : 180;
      if (reduce) {
        flip.set(end);
        return;
      }
      s.busy = true;
      s.held = true;
      // lift it clear of the knob, bring it forward, turn it, hang it back and let it swing
      animate(hook, -DROP, { duration: 0.16, ease: "easeOut" }).then(() => {
        setAbove(true);
        animate(lift, 1, { duration: 0.2, ease: "easeOut" });
        animate(flip, end, { type: "spring", stiffness: 120, damping: 15, mass: 0.9 });
        window.setTimeout(() => {
          animate(lift, 0, { duration: 0.2, ease: "easeIn" }).then(() => {
            setAbove(false);
            animate(hook, 0, { type: "spring", stiffness: 420, damping: 13 });
            s.held = false;
            s.busy = false;
            s.w += next ? 1.5 : -1.5;
          });
        }, 520);
      });
    },
    [flip, hook, lift, reduce],
  );

  // the pendulum, the draught that keeps it alive, and the demo that turns the card over
  useEffect(() => {
    const root = rootRef.current;
    const swing = swingRef.current;
    const shade = shadeRef.current;
    if (!root || !swing || !shade) return;
    const s = live.current;
    if (reduce) {
      s.th = 0;
      flip.set(0);
      setQuiet(true);
      return;
    }
    let raf = 0;
    let last = performance.now();
    let seen = true;
    const frame = (now: number) => {
      raf = requestAnimationFrame(frame);
      const dt = Math.min(0.034, (now - last) / 1000);
      last = now;
      if (!seen || document.hidden) return;
      if (s.drag) {
        const before = s.th;
        s.th += (s.aim - s.th) * Math.min(1, dt * 20);
        s.w = s.w * 0.6 + ((s.th - before) / Math.max(dt, 0.001)) * 0.4;
      } else if (s.held) {
        s.th += (0 - s.th) * Math.min(1, dt * 12);
        s.w = 0;
      } else {
        s.w += (-GRAVITY * Math.sin(s.th) - DRAG * s.w) * dt;
        s.th += s.w * dt;
        s.gust -= dt;
        if (s.gust < 0 && !s.paused) {
          s.w += (Math.random() < 0.5 ? -1 : 1) * 0.42;
          s.gust = 2.2 + Math.random() * 1.8;
        }
      }
      const deg = ((-s.th * 180) / Math.PI).toFixed(3);
      swing.style.transform = `rotate(${deg}deg)`;
      shade.style.transform = `rotate(${deg}deg)`;
      twist.set(s.held ? 0 : clamp(s.w * 5, -18, 18));
      if (!s.touched && !s.paused && !s.busy) {
        s.clock += dt;
        if (s.clock > (flip.get() > 90 ? 0.85 : 4.2)) {
          s.clock = flip.get() > 90 ? 0 : -1.6;
          turnOver();
        }
      }
    };
    raf = requestAnimationFrame(frame);
    const io = new IntersectionObserver((entries) => {
      seen = entries[0]?.isIntersecting ?? true;
    });
    io.observe(root);
    return () => {
      cancelAnimationFrame(raf);
      io.disconnect();
    };
  }, [flip, reduce, turnOver, twist]);

  useEffect(() => {
    const root = rootRef.current;
    const stage = stageRef.current;
    if (!root || !stage) return;
    const measure = () => {
      const sc = clamp(root.clientWidth / 1000, 0.82, 1.16);
      live.current.scale = sc;
      root.style.height = `${Math.round(SCENE_H * sc)}px`;
      stage.style.transform = `scale(${sc.toFixed(4)})`;
      stage.style.top = `${Math.round(KNOB_TOP * sc)}px`;
    };
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(root);
    return () => ro.disconnect();
  }, []);

  const angleTo = (e: ReactPointerEvent) => {
    const stage = stageRef.current;
    if (!stage) return 0;
    const r = stage.getBoundingClientRect();
    return Math.atan2(e.clientX - r.left, Math.max(20, e.clientY - r.top));
  };
  const onDown = (e: ReactPointerEvent<HTMLButtonElement>) => {
    const s = live.current;
    if (e.button !== 0 || s.busy) return;
    s.touched = true;
    s.moved = 0;
    s.x = e.clientX;
    s.y = e.clientY;
    if (reduce) return;
    s.drag = true;
    s.grab = angleTo(e) - s.th;
    s.aim = s.th;
    e.currentTarget.setPointerCapture(e.pointerId);
  };
  const onMove = (e: ReactPointerEvent<HTMLButtonElement>) => {
    const s = live.current;
    if (!s.drag) return;
    s.moved = Math.max(s.moved, Math.hypot(e.clientX - s.x, e.clientY - s.y));
    s.aim = clamp(angleTo(e) - s.grab, -1.15, 1.15);
  };
  const onUp = () => {
    live.current.drag = false;
  };

  const brass = "radial-gradient(circle at 33% 27%, #fff8d8 0%, #f6dc8e 7%, rgba(246,220,142,0) 27%), linear-gradient(172deg, rgba(90,55,5,0) 0%, rgba(90,55,5,0) 50%, rgba(84,50,4,0.5) 56%, rgba(84,50,4,0.12) 78%, rgba(255,226,160,0.3) 100%), radial-gradient(circle at 74% 82%, rgba(255,214,140,0.5) 0%, rgba(255,214,140,0) 26%), radial-gradient(circle at 46% 42%, #dcb052 0%, #c4942f 42%, #99691c 70%, #6c4610 88%, #47300a 100%)";

  return (
    <section
      ref={rootRef}
      onPointerEnter={(e) => {
        if (e.pointerType !== "touch") live.current.paused = true;
      }}
      onPointerLeave={() => (live.current.paused = false)}
      className="relative overflow-hidden bg-[#c3c9b8]"
      style={{ height: SCENE_H * 1.16 }}
    >
      <svg aria-hidden="true" width="0" height="0" className="absolute">
        <filter id="door-hanger-flip-fibre" x="0" y="0" width="100%" height="100%">
          <feTurbulence type="fractalNoise" baseFrequency="0.85 0.6" numOctaves="3" seed="3" />
          <feColorMatrix type="matrix" values="0 0 0 0 0.3  0 0 0 0 0.2  0 0 0 0 0.12  0.9 0 0 0 -0.3" />
        </filter>
        <filter id="door-hanger-flip-paint" x="0" y="0" width="100%" height="100%">
          <feTurbulence type="fractalNoise" baseFrequency="0.4 0.01" numOctaves="3" seed="9" />
          <feColorMatrix type="matrix" values="0 0 0 0 0.16  0 0 0 0 0.2  0 0 0 0 0.12  1.3 0 0 0 -0.52" />
        </filter>
      </svg>

      {/* the painted door: light falling from the upper left, brush marks running with the grain */}
      <div aria-hidden="true" className="pointer-events-none absolute inset-0" style={{ background: "radial-gradient(90% 80% at 30% 18%, #d6dccb 0%, #c3c9b8 46%, #a4ab99 100%)" }} />
      <svg aria-hidden="true" className="pointer-events-none absolute inset-0 h-full w-full opacity-[0.17] mix-blend-multiply">
        <rect width="100%" height="100%" filter="url(#door-hanger-flip-paint)" />
      </svg>

      <div ref={stageRef} className="absolute left-1/2 select-none" style={{ top: KNOB_TOP, width: 0, height: 0, transformOrigin: "0 0" }}>
        {/* sunk panel with its moulding */}
        <div aria-hidden="true" className="pointer-events-none absolute" style={{ left: -690, top: -96, width: 510, height: 900, borderStyle: "solid", borderWidth: 13, borderTopColor: "#97a08c", borderLeftColor: "#a2aa97", borderBottomColor: "#dfe4d5", borderRightColor: "#dce1d2", boxShadow: "0 0 0 1px rgba(255,255,255,0.35), -1px -1px 0 1px rgba(60,70,50,0.22)" }}>
          <div className="absolute inset-0" style={{ background: "linear-gradient(135deg, rgba(60,70,50,0.14), rgba(60,70,50,0) 22%)", boxShadow: "inset 5px 6px 9px rgba(50,60,40,0.3), inset -1px -1px 0 rgba(255,255,255,0.4)" }} />
        </div>
        {/* the edge of the door, the gap, and the frame beyond it */}
        <div aria-hidden="true" className="pointer-events-none absolute" style={{ left: 300, top: -400, width: 900, height: 1400, background: "linear-gradient(90deg, #cdd3c2 0, #e1e6d8 12px, #d3d9c9 26px, #b9c0ad 30px, #d9decf 36px, #dde2d3 92px, #c2c9b6 98px, #e3e8da 104px, #d6dbcc 150px, #b4bba8 156px, #d2d8c8 164px, #d2d8c8 100%)", boxShadow: "-5px 0 0 #3b4034, -6.5px 0 0 rgba(255,255,255,0.5), -11px 0 9px rgba(50,60,40,0.22)" }} />

        {/* back plate of the knob */}
        <div aria-hidden="true" className="pointer-events-none absolute rounded-full" style={{ left: -ROSE, top: -ROSE, width: ROSE * 2, height: ROSE * 2, background: "conic-gradient(from 20deg, #b78930, #ecd083, #a9792a, #e2c16c, #9a6c20, #e8cb7c, #b0812c, #ecd083, #b78930)", boxShadow: "inset 0 2px 1.5px rgba(255,246,205,0.85), inset 0 -2.5px 3px rgba(70,40,0,0.6), 0 1px 1px rgba(40,30,0,0.6), 5px 8px 10px rgba(45,55,35,0.45), 12px 20px 26px rgba(45,55,35,0.22)" }}>
          <div className="absolute rounded-full" style={{ inset: 13, boxShadow: "inset 0 2px 3px rgba(60,35,0,0.7), 0 1px 0 rgba(255,244,200,0.7)", background: "radial-gradient(circle at 40% 30%, #b98a34, #8a611b 70%, #6b490f)" }} />
        </div>

        {/* shadow of the card on the door */}
        <div ref={shadeRef} aria-hidden="true" className="pointer-events-none absolute" style={{ left: -CW / 2, top: -PIVOT_Y, width: CW, height: CH, transformOrigin: `${CW / 2}px ${PIVOT_Y}px`, zIndex: 5 }}>
          <motion.div className="absolute inset-0" style={{ x: shadeX, y: shadeY, scaleX: shadeW, opacity: shadeA, filter: shadeBlur }}>
            <motion.div className="absolute inset-0 bg-[#1e2616]" style={{ clipPath: cut, y: hook }} />
          </motion.div>
          <motion.div className="absolute inset-0" style={{ x: 2.5, y: 3.5, scaleX: shadeW, opacity: shadeNear, filter: "blur(2.5px)" }}>
            <motion.div className="absolute inset-0 bg-[#1e2616]" style={{ clipPath: cut, y: hook }} />
          </motion.div>
        </div>

        {/* the card: one button that turns it over, and something to drag */}
        <button
          ref={swingRef}
          type="button"
          aria-pressed={quiet}
          aria-label={`Do not disturb sign for ${hotel.room}. ${quiet ? sides.quiet.said : sides.service.said}. Press to turn the card over`}
          onPointerDown={onDown}
          onPointerMove={onMove}
          onPointerUp={onUp}
          onPointerCancel={onUp}
          onClick={() => {
            const s = live.current;
            s.touched = true;
            if (s.moved < 6 && !s.busy) {
              setSaid(flip.get() > 90 ? sides.quiet.said : sides.service.said);
              turnOver();
            }
            s.moved = 0;
          }}
          className="absolute block cursor-grab touch-none appearance-none rounded-b-[16px] rounded-t-[32px] border-0 bg-transparent p-0 outline-none focus-visible:ring-4 focus-visible:ring-[#7a1c22] focus-visible:ring-offset-4 focus-visible:ring-offset-[#c3c9b8] active:cursor-grabbing"
          style={{ left: -CW / 2, top: -PIVOT_Y, width: CW, height: CH, transformOrigin: `${CW / 2}px ${PIVOT_Y}px`, perspective: "1100px", zIndex: above ? 30 : 10 }}
        >
          <motion.span className="absolute inset-0 block" style={{ transformStyle: "preserve-3d", rotateY: turn, scale: grow, y: hook }}>
            <Face which="quiet" />
            <Face which="service" />
          </motion.span>
        </button>

        {/* the knob, in front of the card while it hangs */}
        <div aria-hidden="true" className="pointer-events-none absolute rounded-full bg-[#1e2616]" style={{ left: -HEAD + 7, top: -HEAD + 11, width: HEAD * 2, height: HEAD * 2, filter: "blur(6px)", opacity: 0.55, zIndex: 15 }} />
        <div aria-hidden="true" className="pointer-events-none absolute rounded-full" style={{ left: -HEAD, top: -HEAD, width: HEAD * 2, height: HEAD * 2, background: brass, boxShadow: "inset 0 2px 2px rgba(255,248,215,0.7), inset 0 -4px 6px rgba(60,32,0,0.55), 0 0.5px 1px rgba(30,20,0,0.8)", zIndex: 20 }}>
          <div className="absolute rounded-full" style={{ inset: 17, background: "radial-gradient(circle at 36% 30%, #fbe9a8 0%, #d2a441 45%, #8d6118 100%)", boxShadow: "0 0 0 1px rgba(70,40,0,0.55), 0 1.5px 0 1px rgba(255,240,190,0.5), inset 0 -1px 2px rgba(60,32,0,0.5)" }} />
          <div className="absolute rounded-full" style={{ left: "16%", top: "11%", width: "34%", height: "20%", background: "rgba(255,255,255,0.75)", filter: "blur(3px)", transform: "rotate(-32deg)" }} />
        </div>
      </div>

      <p className="pointer-events-none absolute bottom-5 left-5 font-mono text-[11px] uppercase leading-relaxed tracking-[0.18em] text-[#3d4435] sm:bottom-8 sm:left-8">
        <span className="font-semibold text-[#161a11]">
          {hotel.name} / {hotel.room}
        </span>
        <span className="block">Press the card to turn it, drag to swing</span>
      </p>
      <p aria-live="polite" className="sr-only">
        {said}
      </p>
    </section>
  );
}
```
