{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"tear-off-calendar","type":"registry:component","title":"Tear off calendar","description":"Tear a day off to move forward, pull one back to go back.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/tear-off-calendar.tsx","type":"registry:component","content":"\"use client\";\n\nimport {\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from \"react\";\nimport { flushSync } from \"react-dom\";\nimport {\n  animate,\n  motion,\n  useMotionValue,\n  useTransform,\n  type AnimationPlaybackControls,\n  type MotionValue,\n} from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\ntype Flight = {\n  key: number;\n  day: number;\n  y: number;\n  rotate: number;\n  opacity: number;\n  velocity: number;\n  spin: number;\n};\n\n// Dragged up this far, the page tears; a flick does it sooner (px/s).\nconst TEAR_DISTANCE = 80;\nconst FLICK = 500;\n// How far up a previous page waits to be pulled back down: the binding's\n// height, so it starts tucked entirely under the binding and slides out.\nconst REATTACH = 32;\n// The top page lifts this much under a hovering mouse: a hint it moves.\nconst HOVER_LIFT = -4;\n// Pulling down this far brings the previous page most of the way back.\nconst PULL_RANGE = 90;\n// A page leans as it is torn, like one ripped from its right-hand corner.\nconst LEAN = 0.05;\nconst MAX_LEAN = 8;\nconst SNAP_BACK = { type: \"spring\", stiffness: 520, damping: 34 } as const;\nconst REATTACH_SPRING = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 34,\n} as const;\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\nconst MS_PER_DAY = 86_400_000;\n\n// Dates travel as \"YYYY-MM-DD\", like <input type=\"date\">, and are counted\n// in whole UTC days so daylight saving never skips or doubles a page.\nconst toDay = (iso: string) => {\n  const [y, m, d] = iso.split(\"-\").map(Number);\n  return Date.UTC(y, m - 1, d) / MS_PER_DAY;\n};\nconst toIso = (day: number) =>\n  new Date(day * MS_PER_DAY).toISOString().slice(0, 10);\n\nconst utc = { timeZone: \"UTC\" } as const;\nconst fullDate = new Intl.DateTimeFormat(\"en-GB\", {\n  ...utc,\n  weekday: \"long\",\n  day: \"numeric\",\n  month: \"long\",\n  year: \"numeric\",\n});\nconst weekday = new Intl.DateTimeFormat(\"en-GB\", { ...utc, weekday: \"long\" });\nconst monthYear = new Intl.DateTimeFormat(\"en-GB\", {\n  ...utc,\n  month: \"long\",\n  year: \"numeric\",\n});\nconst relative = new Intl.RelativeTimeFormat(\"en-GB\", { numeric: \"auto\" });\n\nfunction describeDay(day: number) {\n  const date = new Date(day * MS_PER_DAY);\n  return {\n    full: fullDate.format(date),\n    weekday: weekday.format(date),\n    month: monthYear.format(date),\n    number: date.getUTCDate(),\n    sunday: date.getUTCDay() === 0,\n  };\n}\n\n// 5 to 12deg of extra lean as it flies: enough to read as torn by hand.\nfunction rollSpin() {\n  return 5 + Math.random() * 7;\n}\n\nfunction relativeTo(day: number, today: number) {\n  const text = relative.format(day - today, \"day\");\n  return text[0].toUpperCase() + text.slice(1);\n}\n\nexport function TearOffCalendar({\n  value,\n  onChange,\n  today,\n  className,\n}: {\n  value: string;\n  onChange: (value: string) => void;\n  // Also \"YYYY-MM-DD\"; drives the relative label and the Today button.\n  today: string;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const hintId = useId();\n  const day = toDay(value);\n  const todayDay = toDay(today);\n  const [flights, setFlights] = useState<Flight[]>([]);\n  const flightKey = useRef(0);\n  const running = useRef<AnimationPlaybackControls[]>([]);\n  const drag = useRef<{ id: number; y: number } | null>(null);\n\n  // The top page: its lift when torn, and its fade when reattaching.\n  const y = useMotionValue(0);\n  const attach = useMotionValue(1);\n  const rotate = useTransform(y, (v) => Math.max(v * LEAN, -MAX_LEAN));\n  // The previous page, pulled down from above: 0 is away, 1 is in place.\n  const pull = useMotionValue(0);\n  const ghostY = useTransform(pull, (p) => -(1 - p) * REATTACH);\n  const ghostRotate = useTransform(pull, (p) => -(1 - p) * 3);\n  // Lifted, the top page rides over the binding; at rest it sits under\n  // the page being pulled back down.\n  const topZ = useTransform(y, (v): number => (v < 0 ? 3 : 0));\n  // Paper is opaque: the page turns solid almost as soon as it moves, so\n  // the two days never show through each other.\n  const ghostOpacity = useTransform(pull, (p) => Math.min(1, p * 6));\n  // The raw finger travel, for release velocity.\n  const travel = useMotionValue(0);\n\n  useEffect(() => {\n    const list = running;\n    return () => list.current.forEach((c) => c.stop());\n  }, []);\n\n  const stopAll = () => {\n    running.current.forEach((c) => c.stop());\n    running.current = [];\n  };\n\n  const tearTo = (target: number, velocity = 0) => {\n    stopAll();\n    const flight: Flight = {\n      key: flightKey.current++,\n      day,\n      y: y.get(),\n      rotate: rotate.get(),\n      opacity: attach.get(),\n      velocity,\n      // Rolled per tear, so no two pages leave the same way.\n      spin: rollSpin(),\n    };\n    // The next page is shown at once and the torn one flies off as a copy,\n    // so the next tear never waits on the last.\n    flushSync(() => {\n      onChange(toIso(target));\n      if (!reduceMotion) setFlights((list) => [...list, flight]);\n    });\n    y.jump(0);\n    attach.jump(1);\n    pull.jump(0);\n  };\n\n  const reattachTo = (target: number, from = 0) => {\n    stopAll();\n    flushSync(() => onChange(toIso(target)));\n    pull.jump(0);\n    if (reduceMotion) {\n      y.jump(0);\n      attach.jump(0);\n      running.current = [animate(attach, 1, { duration: 0.15 })];\n      return;\n    }\n    // Picks up exactly where the pulled-down page was.\n    y.jump(-(1 - from) * REATTACH);\n    attach.jump(from);\n    running.current = [\n      animate(y, 0, REATTACH_SPRING),\n      animate(attach, 1, { duration: 0.18, ease: EASE_OUT }),\n    ];\n  };\n\n  const goTo = (target: number) => {\n    if (target > day) tearTo(target, -400);\n    else if (target < day) reattachTo(target);\n  };\n\n  const release = (e: React.PointerEvent) => {\n    if (drag.current?.id !== e.pointerId) return;\n    drag.current = null;\n    const distance = travel.get();\n    const velocity = travel.getVelocity();\n    if (distance < 0) {\n      if (-distance > TEAR_DISTANCE || velocity < -FLICK) {\n        tearTo(day + 1, Math.min(velocity, 0));\n      } else {\n        running.current = [animate(y, 0, { ...SNAP_BACK, velocity })];\n      }\n    } else if (distance > 0) {\n      if (pull.get() > 0.5 || velocity > FLICK) {\n        reattachTo(day - 1, pull.get());\n      } else {\n        running.current = [animate(pull, 0, SNAP_BACK)];\n      }\n    }\n  };\n\n  const shown = describeDay(day);\n\n  return (\n    // Clips sideways only: a torn page swinging out must never widen the\n    // page, but it may fly up freely.\n    <div\n      className={cn(\n        \"flex w-[min(380px,100%)] flex-col items-center gap-5 overflow-x-clip px-5 pt-2 pb-4\",\n        className,\n      )}\n    >\n      <div className=\"relative w-full\">\n        {/* Page thickness under the pad: a year of days still to go. */}\n        <span\n          aria-hidden\n          className=\"absolute inset-x-[18px] -bottom-[13px] h-8 rounded-b-[14px] bg-background shadow-raised\"\n        />\n        <span\n          aria-hidden\n          className=\"absolute inset-x-3 -bottom-[9px] h-8 rounded-b-[16px] bg-background shadow-raised\"\n        />\n        <span\n          aria-hidden\n          className=\"absolute inset-x-1.5 -bottom-[5px] h-8 rounded-b-[18px] bg-background shadow-raised\"\n        />\n\n        <div className=\"relative rounded-[20px] bg-background shadow-raised\">\n          {/* The binding. Above the pages, so a page pulled back down\n              slides out from under it; torn pages fly over it. */}\n          <div className=\"relative z-[2] flex h-8 items-center justify-center gap-24 rounded-t-[20px] bg-foreground\">\n            {/* Two brass rivets: the binding's hardware, a physical metal,\n                the same in both themes. */}\n            <span className=\"size-2.5 rounded-full bg-[radial-gradient(circle_at_35%_35%,#f3dfae,#b8914a_60%,#7d6130)] shadow-[0_1px_1px_oklch(0_0_0/0.4)]\" />\n            <span className=\"size-2.5 rounded-full bg-[radial-gradient(circle_at_35%_35%,#f3dfae,#b8914a_60%,#7d6130)] shadow-[0_1px_1px_oklch(0_0_0/0.4)]\" />\n          </div>\n\n          <div\n            role=\"spinbutton\"\n            tabIndex={0}\n            aria-label=\"Date\"\n            aria-valuenow={day - todayDay}\n            aria-valuetext={shown.full}\n            aria-describedby={hintId}\n            className=\"relative h-[272px] cursor-grab touch-none rounded-b-[20px] outline-hidden select-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-4 focus-visible:outline-foreground active:cursor-grabbing\"\n            onKeyDown={(e) => {\n              const step: Record<string, number> = {\n                ArrowUp: 1,\n                ArrowRight: 1,\n                ArrowDown: -1,\n                ArrowLeft: -1,\n              };\n              if (e.key in step) goTo(day + step[e.key]);\n              else if (e.key === \"Home\") goTo(todayDay);\n              else return;\n              e.preventDefault();\n            }}\n            onPointerDown={(e) => {\n              if (drag.current || e.button !== 0) return;\n              e.currentTarget.setPointerCapture(e.pointerId);\n              stopAll();\n              drag.current = { id: e.pointerId, y: e.clientY - travelFrom() };\n              travel.jump(travelFrom());\n            }}\n            onPointerMove={(e) => {\n              const d = drag.current;\n              if (!d || d.id !== e.pointerId) return;\n              const distance = e.clientY - d.y;\n              travel.set(distance);\n              if (distance <= 0) {\n                y.set(distance);\n                pull.set(0);\n              } else {\n                y.set(0);\n                // Eases in, so the page never quite snaps into place early.\n                pull.set(1 - Math.exp(-distance / PULL_RANGE));\n              }\n            }}\n            onPointerUp={release}\n            onPointerCancel={release}\n            onPointerEnter={(e) => {\n              if (e.pointerType !== \"mouse\" || drag.current || reduceMotion)\n                return;\n              stopAll();\n              running.current = [animate(y, HOVER_LIFT, SNAP_BACK)];\n            }}\n            onPointerLeave={(e) => {\n              if (e.pointerType !== \"mouse\" || drag.current || y.get() >= 0)\n                return;\n              stopAll();\n              running.current = [animate(y, 0, SNAP_BACK)];\n            }}\n          >\n            {/* Tomorrow, waiting underneath. */}\n            <Page day={day + 1} />\n            <Page\n              day={day}\n              y={y}\n              rotate={rotate}\n              opacity={attach}\n              z={topZ}\n              raised\n            />\n            {/* Yesterday, only there while you pull it back down. */}\n            <Page\n              day={day - 1}\n              y={ghostY}\n              rotate={ghostRotate}\n              opacity={ghostOpacity}\n              raised\n              className=\"z-[1]\"\n            />\n            {flights.map((flight) => (\n              <FlyingPage\n                className=\"z-[3]\"\n                key={flight.key}\n                flight={flight}\n                onGone={() =>\n                  setFlights((list) => list.filter((f) => f.key !== flight.key))\n                }\n              />\n            ))}\n          </div>\n        </div>\n      </div>\n\n      <span id={hintId} className=\"sr-only\">\n        Arrow keys change the day, Home returns to today. Drag the page up for\n        the next day, down for the previous one.\n      </span>\n\n      <div className=\"mt-2 flex flex-col items-center gap-0.5 text-center\">\n        <p className=\"text-[15px] font-medium text-foreground tabular-nums\">\n          {shown.full}\n        </p>\n        <p className=\"text-sm text-muted-foreground\" aria-live=\"polite\">\n          {relativeTo(day, todayDay)}\n        </p>\n      </div>\n\n      <div className=\"flex gap-2\">\n        <IconButton label=\"Previous day\" onClick={() => goTo(day - 1)}>\n          <path d=\"m10 3.5-4.5 4.5 4.5 4.5\" />\n        </IconButton>\n        <button\n          type=\"button\"\n          onClick={() => goTo(todayDay)}\n          disabled={day === todayDay}\n          className=\"h-10 touch-manipulation rounded-full bg-muted px-4 text-sm font-medium text-foreground shadow-raised outline-hidden transition-[scale,opacity] duration-150 ease-out select-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground active:scale-[0.96] disabled:opacity-50 disabled:active:scale-100 motion-reduce:transition-[opacity]\"\n        >\n          Today\n        </button>\n        <IconButton label=\"Next day\" onClick={() => goTo(day + 1)}>\n          <path d=\"m6 3.5 4.5 4.5L6 12.5\" />\n        </IconButton>\n      </div>\n    </div>\n  );\n\n  // A grab while the page is still springing back continues from where it\n  // visibly is, instead of jumping under the finger.\n  function travelFrom() {\n    if (y.get() < 0) return y.get();\n    const p = pull.get();\n    return p > 0 ? -PULL_RANGE * Math.log(1 - Math.min(p, 0.99)) : 0;\n  }\n}\n\nfunction Page({\n  day,\n  y,\n  rotate,\n  opacity,\n  raised,\n  torn,\n  className,\n  z,\n}: {\n  z?: MotionValue<number>;\n  day: number;\n  y?: MotionValue<number>;\n  rotate?: MotionValue<number>;\n  opacity?: MotionValue<number>;\n  raised?: boolean;\n  torn?: boolean;\n  className?: string;\n}) {\n  const d = describeDay(day);\n  return (\n    <motion.div\n      aria-hidden\n      style={{ y, rotate, opacity, zIndex: z }}\n      className={cn(\n        \"pointer-events-none absolute inset-0 origin-top-left\",\n        className,\n        // Shadow as a filter outside the mask, so it follows the torn edge\n        // instead of being masked away with it.\n        torn &&\n          \"[filter:drop-shadow(0_0_0.5px_oklch(0_0_0/0.2))_drop-shadow(0_8px_16px_oklch(0_0_0/0.12))] dark:[filter:drop-shadow(0_0_0.75px_oklch(1_0_0/0.25))_drop-shadow(0_8px_16px_oklch(0_0_0/0.5))]\",\n      )}\n    >\n      <div\n        className={cn(\n          \"relative flex size-full flex-col items-center justify-center rounded-b-[20px] bg-background\",\n          raised &&\n            \"shadow-[0_1px_0_0_var(--border),0_1px_3px_oklch(0_0_0/0.06)]\",\n          // A torn page keeps half of each perforation hole along its top.\n          torn &&\n            \"[mask:radial-gradient(circle_at_5px_0,transparent_2px,black_2.5px)_0_0/10px_100%_repeat-x]\",\n        )}\n      >\n        {/* The perforation it tears along, just under the binding. */}\n        {!torn && (\n          <span className=\"absolute inset-x-3 top-1.5 h-1.5 bg-[radial-gradient(circle,color-mix(in_oklch,var(--foreground)_22%,transparent)_1.25px,transparent_1.75px)] bg-[length:10px_6px] bg-repeat-x\" />\n        )}\n        <span className=\"text-[13px] font-medium tracking-[0.14em] text-muted-foreground uppercase\">\n          {d.month}\n        </span>\n        <span\n          className={cn(\n            \"mt-1 text-[112px] leading-none font-semibold tracking-tighter tabular-nums\",\n            d.sunday ? \"text-destructive\" : \"text-foreground\",\n          )}\n        >\n          {d.number}\n        </span>\n        <span className=\"mt-3 text-lg text-foreground\">{d.weekday}</span>\n      </div>\n    </motion.div>\n  );\n}\n\nfunction FlyingPage({\n  flight,\n  onGone,\n  className,\n}: {\n  flight: Flight;\n  onGone: () => void;\n  className?: string;\n}) {\n  const y = useMotionValue(flight.y);\n  const rotate = useMotionValue(flight.rotate);\n  const opacity = useMotionValue(flight.opacity);\n  const gone = useRef(onGone);\n\n  useEffect(() => {\n    gone.current = onGone;\n  });\n\n  useEffect(() => {\n    // Keeps the speed the hand tore it at, leans on as it goes, and is\n    // faded well before it clears the pad. An exit nothing waits on.\n    const all = [\n      animate(y, flight.y - 200, {\n        type: \"spring\",\n        stiffness: 220,\n        damping: 30,\n        velocity: flight.velocity,\n      }),\n      animate(rotate, flight.rotate - flight.spin, {\n        duration: 0.4,\n        ease: EASE_OUT,\n      }),\n      animate(opacity, 0, { duration: 0.22, delay: 0.08, ease: EASE_OUT }),\n    ];\n    all[2].then(() => gone.current());\n    return () => all.forEach((c) => c.stop());\n  }, [flight, y, rotate, opacity]);\n\n  return (\n    <Page\n      day={flight.day}\n      y={y}\n      rotate={rotate}\n      opacity={opacity}\n      torn\n      className={className}\n    />\n  );\n}\n\nfunction IconButton({\n  label,\n  onClick,\n  children,\n}: {\n  label: string;\n  onClick: () => void;\n  children: React.ReactNode;\n}) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={label}\n      onClick={onClick}\n      className=\"flex size-10 touch-manipulation items-center justify-center rounded-full bg-muted text-foreground shadow-raised outline-hidden transition-[scale] duration-150 ease-out select-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground active:scale-[0.96] motion-reduce:transition-none\"\n    >\n      <svg\n        viewBox=\"0 0 16 16\"\n        className=\"size-4\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={1.5}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        aria-hidden\n      >\n        {children}\n      </svg>\n    </button>\n  );\n}\n\n// Server and first client render agree on a fixed date; the real local day\n// takes over right after hydration, so the markup never mismatches.\nconst FALLBACK = \"2026-09-23\";\nconst noSubscribe = () => () => {};\nfunction localToday() {\n  const now = new Date();\n  const pad = (n: number) => String(n).padStart(2, \"0\");\n  return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;\n}\n\nexport default function TearOffCalendarDemo() {\n  const today = useSyncExternalStore(noSubscribe, localToday, () => FALLBACK);\n  // Stored as days from today, so the page follows once the real date loads.\n  const [offset, setOffset] = useState(0);\n  const value = toIso(toDay(today) + offset);\n  return (\n    <TearOffCalendar\n      value={value}\n      today={today}\n      onChange={(next) => setOffset(toDay(next) - toDay(today))}\n    />\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["objects"],"docs":"From ui lab: https://lab.xevrion.dev/lab/tear-off-calendar"}