Logo SmoothUI
ComponentsApp Download Stack

App Download Stack

Inspired by Family.co and the example by Jenson Wong, this component presents a stack of apps, allowing users to open the stack, select the apps they want, and download them.

Starter Mac

4 Applications

Code

Install with shadcn Beta

Terminal

npx shadcn@latest add "https://smoothui.dev/r/app-download-stack.json"

Manual install

Terminal

npm install motion lucide-react

AppDownloadStack.tsx

"use client"

import { useCallback, useMemo, useState } from "react"
import { ChevronDown } from "lucide-react"
import { AnimatePresence, motion, useAnimation } from "motion/react"

const Canary =
  "https://parsefiles.back4app.com/JPaQcFfEEQ1ePBxbf6wvzkPMEqKYHhPYv8boI1Rc/b47f43e02f04563447fa90d4ff6c8943_9KzW5GTggQ.png"

const Github =
  "https://parsefiles.back4app.com/JPaQcFfEEQ1ePBxbf6wvzkPMEqKYHhPYv8boI1Rc/9c9721583ecba33e59ebcebdca2248fd_Mmr12FRh5V.png"

const Figma =
  "https://parsefiles.back4app.com/JPaQcFfEEQ1ePBxbf6wvzkPMEqKYHhPYv8boI1Rc/f0b9cdefa67b57eeb080278c2f6984cc_sCqUJBg6Qq.png"

const Arc =
  "https://parsefiles.back4app.com/JPaQcFfEEQ1ePBxbf6wvzkPMEqKYHhPYv8boI1Rc/178c7b02003c933e6b5afe98bbee595b_low_res_Arc_Browser.png"

const STARTER_KIT_TITLE = "Starter Mac"

const apps = [
  { id: 1, name: "GitHub", icon: Github },
  { id: 2, name: "Canary", icon: Canary },
  { id: 3, name: "Figma", icon: Figma },
  { id: 4, name: "Arc", icon: Arc },
]

const useAppDownloader = () => {
  const [isExpanded, setIsExpanded] = useState(false)
  const [selectedApps, setSelectedApps] = useState<number[]>([])
  const [isDownloading, setIsDownloading] = useState(false)
  const [downloadComplete, setDownloadComplete] = useState(false)
  const shineControls = useAnimation()

  const toggleApp = useCallback((id: number) => {
    setSelectedApps((prev) =>
      prev.includes(id) ? prev.filter((appId) => appId !== id) : [...prev, id]
    )
  }, [])

  const handleDownload = useCallback(() => {
    setIsDownloading(true)
  }, [])

  const confirmDownload = useCallback(() => {
    setIsDownloading(true)
    shineControls.start({
      x: ["0%", "100%"],
      transition: { duration: 1, repeat: Infinity, ease: "linear" },
    })
    setTimeout(() => {
      shineControls.stop()
      setDownloadComplete(true)
      setTimeout(() => {
        setIsExpanded(false)
        setSelectedApps([])
        setIsDownloading(false)
        setDownloadComplete(false)
      }, 1000)
    }, 3000)
  }, [shineControls])

  return {
    isExpanded,
    setIsExpanded,
    selectedApps,
    isDownloading,
    downloadComplete,
    toggleApp,
    handleDownload,
    confirmDownload,
    shineControls,
  }
}

export default function AppDownloadStack() {
  const {
    isExpanded,
    setIsExpanded,
    selectedApps,
    isDownloading,
    downloadComplete,
    toggleApp,
    handleDownload,
    confirmDownload,
    shineControls,
  } = useAppDownloader()

  const stackVariants = useMemo(
    () => ({
      initial: (i: number) => ({
        rotate: i % 2 === 0 ? -8 * (i + 1) : 8 * (i + 1),
        x: i % 2 === 0 ? -3 * (i + 1) : 3 * (i + 1),
        y: 0,
        zIndex: 40 - i * 10,
      }),
      hover: (i: number) => ({
        rotate: 0,
        x: i * 10,
        y: -i * 10,
        zIndex: 40 - i * 10,
      }),
      float: (i: number) => ({
        y: [0, -5, 0],
        transition: {
          y: {
            repeat: Infinity,
            duration: 2,
            ease: "easeInOut",
            delay: i * 0.2,
          },
        },
      }),
    }),
    []
  )

  return (
    <div className="flex h-auto flex-col items-center justify-center">
      <motion.div layout className="flex flex-col items-center justify-center">
        <AnimatePresence mode="wait">
          {!isExpanded && !isDownloading && (
            <motion.button
              key="initial-stack"
              className="group relative isolate flex h-16 w-16 cursor-pointer items-center justify-center"
              onClick={() => setIsExpanded(true)}
              whileHover="hover"
              layout
              aria-label="Expand app selection"
            >
              {apps.map((app, index) => (
                <motion.img
                  key={app.id}
                  layoutId={`app-icon-${app.id}`}
                  src={app.icon}
                  width={64}
                  height={64}
                  alt={`${app.name} Logo`}
                  className="absolute inset-0 rounded-xl border-none"
                  custom={index}
                  variants={stackVariants}
                  initial="initial"
                  animate={["initial", "float"]}
                  whileHover="hover"
                  transition={{ duration: 0.3 }}
                />
              ))}
            </motion.button>
          )}

          {isExpanded && !isDownloading && (
            <motion.div
              className="flex flex-col items-center gap-2"
              key="app-selector"
              initial={{ opacity: 0, scale: 0.8 }}
              animate={{ opacity: 1, scale: 1 }}
              exit={{ opacity: 0, scale: 0.8 }}
              layout
            >
              <button
                className="flex w-full cursor-pointer items-center justify-between px-0.5"
                onClick={() => setIsExpanded(false)}
              >
                <p className="my-0 leading-0 font-medium">
                  {STARTER_KIT_TITLE}
                </p>
                <div className="flex items-center gap-1">
                  <p className="my-0 leading-0 font-medium">
                    {selectedApps.length}
                  </p>
                  <ChevronDown size={16} className="text-mauve-11" />
                </div>
              </button>
              <motion.ul className="grid grid-cols-2 gap-3">
                {apps.map((app, index) => (
                  <motion.li
                    key={app.id}
                    className="relative flex h-[80px] w-[80px]"
                    initial={{ opacity: 0, y: 20 }}
                    animate={{ opacity: 1, y: 0 }}
                    transition={{ delay: index * 0.1 }}
                  >
                    <div
                      className={`pointer-events-none absolute top-2 right-2 flex h-4 w-4 items-center justify-center rounded-full border border-solid ${
                        selectedApps.includes(app.id)
                          ? "border-blue-500 bg-blue-500"
                          : "border-white/60"
                      }`}
                    >
                      {selectedApps.includes(app.id) && (
                        <motion.svg
                          xmlns="http://www.w3.org/2000/svg"
                          viewBox="0 0 24 24"
                          fill="none"
                          stroke="white"
                          strokeWidth="2"
                          strokeLinecap="round"
                          strokeLinejoin="round"
                          className="z-1 h-3 w-3"
                          initial={{ pathLength: 0 }}
                          animate={{ pathLength: 1 }}
                          transition={{ duration: 0.3 }}
                        >
                          <polyline points="20 6 9 17 4 12"></polyline>
                        </motion.svg>
                      )}
                    </div>
                    <button
                      className="cursor-pointer"
                      onClick={() => toggleApp(app.id)}
                      aria-label={`${app.name} ${
                        selectedApps.includes(app.id)
                          ? "Selected"
                          : "Unselected"
                      }`}
                    >
                      <motion.img
                        layoutId={`app-icon-${app.id}`}
                        src={app.icon}
                        width={80}
                        height={80}
                        alt={`${app.name} Logo`}
                        className="z-0 w-auto rounded-xl border-none"
                      />
                    </button>
                  </motion.li>
                ))}
              </motion.ul>
              <motion.button
                layoutId="download-button"
                className={`border-light-200 dark:border-dark-200 mt-2 w-full rounded-full border p-3 py-2 font-sans font-medium shadow-sm transition ${
                  selectedApps.length > 0
                    ? "bg-light-50 dark:bg-dark-50 cursor-pointer"
                    : "cursor-not-allowed"
                }`}
                disabled={selectedApps.length === 0}
                onClick={handleDownload}
                whileHover={selectedApps.length > 0 ? { scale: 1.05 } : {}}
                whileTap={selectedApps.length > 0 ? { scale: 0.95 } : {}}
              >
                Download
              </motion.button>
            </motion.div>
          )}

          {isDownloading && (
            <motion.div
              key="download-confirmation"
              initial={{ opacity: 0, scale: 0.8 }}
              animate={{ opacity: 1, scale: 1 }}
              exit={{ opacity: 0, scale: 0.8 }}
              className="flex flex-col items-center gap-8"
              layout
            >
              <motion.div
                className="relative h-16 w-16"
                initial={{ scale: 1, opacity: 0 }}
                animate={{ scale: 1, opacity: 1 }}
                transition={{ duration: 0.5 }}
              >
                {selectedApps.map((appId, index) => {
                  const app = apps.find((a) => a.id === appId)
                  return (
                    <motion.img
                      key={appId}
                      layoutId={`app-icon-${appId}`}
                      src={app?.icon}
                      width={64}
                      height={64}
                      alt={`${app?.name} Logo`}
                      className="absolute inset-0 h-16 w-16 rounded-xl border-none"
                      style={{
                        rotate: index % 2 === 0 ? "12deg" : "-8deg",
                        transformOrigin: "50% 50% 0px",
                      }}
                    />
                  )
                })}
              </motion.div>
              <motion.div className="relative w-full overflow-hidden rounded-full">
                <motion.button
                  layoutId="download-button"
                  className={`border-light-200 bg-light-50 text-light-950 dark:border-dark-200 dark:bg-dark-50 dark:text-dark-950 relative w-full cursor-pointer rounded-full border p-3 px-4 py-2 font-sans font-medium shadow-xs transition`}
                  onClick={confirmDownload}
                  disabled={downloadComplete}
                >
                  <motion.div
                    className="absolute inset-0 bg-linear-to-r from-transparent via-black/20 to-transparent opacity-30 dark:via-white"
                    initial={{ x: "-100%" }}
                    animate={shineControls}
                  />
                  <span className="relative z-10">
                    {downloadComplete
                      ? "🎉 Download Complete!"
                      : `Confirm downloading ${selectedApps.length} app${
                          selectedApps.length > 1 ? "s" : ""
                        }`}
                  </span>
                </motion.button>
              </motion.div>
            </motion.div>
          )}
        </AnimatePresence>

        {!isExpanded && !isDownloading && (
          <motion.div
            initial={{ opacity: 0, y: 20 }}
            animate={{ opacity: 1, y: 0 }}
            transition={{ delay: 0.5 }}
            className="mt-4 text-center"
          >
            <h2 className="text-light-950 dark:text-dark-950 text-xl font-bold">
              {STARTER_KIT_TITLE}
            </h2>
            <p className="text-light-900 dark:text-dark-900">
              {apps.length} Applications
            </p>
          </motion.div>
        )}
      </motion.div>
    </div>
  )
}