{"$schema":"https://ui.shadcn.com/schema/registry.json","author":"Kaiyu Hsu <uicapsule@kyh.io>","categories":["cards-grids","minimal"],"dependencies":["lucide-react","motion"],"description":"iOS Smart Stack — widgets roll through a vertical 3D page-flip, auto-advancing until you swipe.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { WidgetStack } from \"./widget-stack\";\n\nconst Preview = () => (\n  <main className=\"flex h-dvh items-center justify-center overflow-hidden bg-[radial-gradient(circle_at_18%_22%,rgb(99_102_241/0.55)_0%,transparent_42%),radial-gradient(circle_at_85%_80%,rgb(139_92_246/0.45)_0%,transparent_45%),radial-gradient(circle_at_78%_12%,rgb(59_130_246/0.35)_0%,transparent_38%),linear-gradient(160deg,#1e1b4b_0%,#14113a_55%,#0a0823_100%)] p-5\">\n    <WidgetStack />\n  </main>\n);\n\nexport default Preview;\n","path":"/preview.tsx","target":"uicapsule/widget-stack/preview.tsx","type":"registry:file"},{"content":"\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport type { FC, ReactNode } from \"react\";\nimport { BatteryCharging, CalendarDays, CloudSun, Image as ImageIcon } from \"lucide-react\";\nimport { AnimatePresence, MotionConfig, motion, useReducedMotion } from \"motion/react\";\n\nconst AUTO_ADVANCE_MS = 4000;\n\nconst WeatherWidget: FC = () => (\n  <div className=\"flex h-full flex-col justify-between bg-gradient-to-b from-[#2563eb] to-[#0c4a9e] p-5\">\n    <div>\n      <p className=\"text-[13px] font-medium text-white/85\">Kyoto</p>\n      <p className=\"mt-1 text-[40px] leading-none font-light text-white\">21°</p>\n    </div>\n    <div className=\"flex items-center justify-between\">\n      <span className=\"flex items-center gap-1.5 text-[12px] font-medium text-white/85\">\n        <CloudSun className=\"size-4\" /> Partly cloudy\n      </span>\n      <span className=\"text-[12px] text-white/60\">H:26° L:17°</span>\n    </div>\n  </div>\n);\n\nconst CalendarWidget: FC = () => (\n  <div className=\"flex h-full flex-col bg-[#1a1833] p-5\">\n    <p className=\"text-[12px] font-semibold text-red-400 uppercase\">Tuesday</p>\n    <p className=\"text-[34px] leading-tight font-light text-white\">7</p>\n    <div className=\"mt-auto space-y-2\">\n      <div className=\"rounded-lg border-l-[3px] border-sky-400 bg-sky-400/10 px-2.5 py-1.5\">\n        <p className=\"text-[12px] font-medium text-white\">Design crit</p>\n        <p className=\"text-[10px] text-white/50\">11:00 AM</p>\n      </div>\n      <div className=\"rounded-lg border-l-[3px] border-violet-400 bg-violet-400/10 px-2.5 py-1.5\">\n        <p className=\"text-[12px] font-medium text-white\">Capsule review</p>\n        <p className=\"text-[10px] text-white/50\">2:00 PM</p>\n      </div>\n    </div>\n  </div>\n);\n\nconst PhotoWidget: FC = () => (\n  <div className=\"relative h-full bg-[radial-gradient(circle_at_30%_30%,#f59e0b_0%,#dc2626_45%,#4c1d95_100%)] p-5\">\n    <div className=\"absolute right-4 bottom-4 left-4\">\n      <p className=\"flex items-center gap-1.5 text-[11px] font-medium text-white/80\">\n        <ImageIcon className=\"size-3.5\" /> On this day\n      </p>\n      <p className=\"text-[14px] font-semibold text-white\">Lisbon, 2024</p>\n    </div>\n  </div>\n);\n\nconst BatteryWidget: FC = () => (\n  <div className=\"flex h-full items-center justify-around bg-[#1a1833] p-5\">\n    {[\n      [\"iPhone\", 82, \"#30d158\"],\n      [\"Watch\", 54, \"#ffd60a\"],\n      [\"Pods\", 26, \"#ff453a\"],\n    ].map(([label, percent, color]) => (\n      <div key={String(label)} className=\"flex flex-col items-center gap-2\">\n        <div className=\"relative size-14\">\n          <svg viewBox=\"0 0 56 56\" className=\"size-14 -rotate-90\">\n            <circle\n              cx=\"28\"\n              cy=\"28\"\n              r=\"24\"\n              fill=\"none\"\n              stroke=\"rgba(255,255,255,0.12)\"\n              strokeWidth=\"5\"\n            />\n            <circle\n              cx=\"28\"\n              cy=\"28\"\n              r=\"24\"\n              fill=\"none\"\n              stroke={String(color)}\n              strokeWidth=\"5\"\n              strokeLinecap=\"round\"\n              strokeDasharray={`${(Number(percent) / 100) * 150.8} 150.8`}\n            />\n          </svg>\n          <span className=\"absolute inset-0 grid place-items-center text-[11px] font-semibold text-white\">\n            {percent}\n          </span>\n        </div>\n        <p className=\"text-[10px] text-white/50\">{label}</p>\n      </div>\n    ))}\n  </div>\n);\n\nconst WIDGETS: { id: string; label: string; icon: FC<{ className?: string }>; node: ReactNode }[] =\n  [\n    { icon: CloudSun, id: \"weather\", label: \"Weather\", node: <WeatherWidget /> },\n    { icon: CalendarDays, id: \"calendar\", label: \"Calendar\", node: <CalendarWidget /> },\n    { icon: ImageIcon, id: \"photos\", label: \"Photos\", node: <PhotoWidget /> },\n    { icon: BatteryCharging, id: \"battery\", label: \"Batteries\", node: <BatteryWidget /> },\n  ];\n\nconst variants = {\n  center: { opacity: 1, rotateX: 0, scale: 1, y: \"0%\" },\n  enter: (direction: number) => ({\n    opacity: 0,\n    rotateX: direction > 0 ? -78 : 78,\n    scale: 0.92,\n    y: direction > 0 ? \"38%\" : \"-38%\",\n  }),\n  exit: (direction: number) => ({\n    opacity: 0,\n    rotateX: direction > 0 ? 78 : -78,\n    scale: 0.92,\n    y: direction > 0 ? \"-38%\" : \"38%\",\n  }),\n};\n\ninterface Page {\n  index: number;\n  direction: 1 | -1;\n}\n\nconst wrap = (value: number) => (value + WIDGETS.length) % WIDGETS.length;\n\nexport const WidgetStack = () => {\n  const [page, setPage] = useState<Page>({ direction: 1, index: 0 });\n  const { index, direction } = page;\n  // Auto-rotation is looping motion the user never asked for; reduced motion keeps it manual.\n  const reduced = useReducedMotion() ?? false;\n\n  const goTo = (next: number, nextDirection: 1 | -1) => {\n    setPage({ direction: nextDirection, index: wrap(next) });\n  };\n\n  useEffect(() => {\n    if (reduced) {\n      return;\n    }\n    // Keyed on the index so any page change, swipe or auto, restarts the countdown.\n    const timer = setTimeout(() => {\n      setPage({ direction: 1, index: wrap(index + 1) });\n    }, AUTO_ADVANCE_MS);\n    return () => clearTimeout(timer);\n  }, [index, reduced]);\n\n  const widget = WIDGETS[index];\n\n  return (\n    <MotionConfig reducedMotion=\"user\">\n      <div className=\"flex flex-col items-center gap-5 select-none\">\n        <div className=\"flex items-center gap-4\">\n          <div className=\"relative h-[170px] w-[300px]\" style={{ perspective: 900 }}>\n            <AnimatePresence initial={false} custom={direction} mode=\"popLayout\">\n              <motion.div\n                key={widget?.id ?? index}\n                custom={direction}\n                variants={variants}\n                initial=\"enter\"\n                animate=\"center\"\n                exit=\"exit\"\n                transition={{ bounce: 0.2, duration: 0.55, type: \"spring\" }}\n                drag=\"y\"\n                dragConstraints={{ bottom: 0, top: 0 }}\n                dragElastic={0.28}\n                onDragEnd={(_, info) => {\n                  if (info.offset.y < -40 || info.velocity.y < -400) {\n                    goTo(index + 1, 1);\n                  } else if (info.offset.y > 40 || info.velocity.y > 400) {\n                    goTo(index - 1, -1);\n                  }\n                }}\n                className=\"absolute inset-0 cursor-grab overflow-hidden rounded-[24px] shadow-2xl shadow-black/50 ring-1 ring-white/10 active:cursor-grabbing\"\n                style={{ transformStyle: \"preserve-3d\" }}\n              >\n                {widget?.node}\n              </motion.div>\n            </AnimatePresence>\n          </div>\n\n          <div className=\"flex flex-col gap-1.5\">\n            {WIDGETS.map((entry, dotIndex) => (\n              <button\n                type=\"button\"\n                key={entry.id}\n                aria-label={`Show ${entry.label}`}\n                onClick={() => goTo(dotIndex, dotIndex > index ? 1 : -1)}\n                className={`size-1.5 rounded-full transition-colors ${\n                  dotIndex === index ? \"bg-white\" : \"bg-white/25\"\n                }`}\n              />\n            ))}\n          </div>\n        </div>\n\n        <p className=\"text-[11px] text-white/30\">\n          {reduced ? \"Swipe the widget vertically\" : \"Swipe the widget vertically — or wait\"}\n        </p>\n      </div>\n    </MotionConfig>\n  );\n};\n","path":"/widget-stack.tsx","target":"uicapsule/widget-stack/widget-stack.tsx","type":"registry:file"}],"homepage":"https://www.uicapsule.com/ui/widget-stack","name":"widget-stack","registryDependencies":[],"title":"Widget Stack","type":"registry:block"}