All components

App Download Stack

App Download Stack is a React and Tailwind CSS component from SmoothUI, licensed MIT. Copy it and paste it into your project.

SmoothUI MIT other

What it is

App Download Stack is a React and Tailwind CSS component from SmoothUI, licensed MIT. Copy it and paste it into your project.

How to use it

Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.

Open in the catalogue

Five free copies a month. No card.

Needs

npm i motion lucide-react

Licence

This component comes from SmoothUI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.

App Download Stack

index.tsx
"use client";

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

export interface AppData {
  icon: string;
  id: number;
  name: string;
}

export interface AppDownloadStackProps {
  apps?: AppData[];
  className?: string;
  isExpanded?: boolean;
  onChange?: (selected: number[]) => void;
  onDownload?: (selected: number[]) => void;
  onExpandChange?: (expanded: boolean) => void;
  selectedApps?: number[];
  title?: string;
}

const DOWNLOAD_DURATION_MS = 3000;
const RESET_DELAY_MS = 1000;
const ROTATION_MULTIPLIER = 8;
const TRANSLATION_MULTIPLIER = 3;
const BASE_Z_INDEX = 40;
const Z_INDEX_STEP = 10;
const HOVER_X_MULTIPLIER = 10;
const HOVER_Y_MULTIPLIER = 10;
const FLOAT_AMPLITUDE = 5;
const FLOAT_DURATION = 2;
const FLOAT_DELAY_MULTIPLIER = 0.2;
const STAGGER_DELAY_MULTIPLIER = 0.1;
const TRANSITION_DURATION = 0.3;
const CHECKMARK_TRANSITION_DURATION = 0.3;

const defaultApps: AppData[] = [
  {
    icon: "https://parsefiles.back4app.com/JPaQcFfEEQ1ePBxbf6wvzkPMEqKYHhPYv8boI1Rc/9c9721583ecba33e59ebcebdca2248fd_Mmr12FRh5V.png",
    id: 1,
    name: "GitHub",
  },
  {
    icon: "https://parsefiles.back4app.com/JPaQcFfEEQ1ePBxbf6wvzkPMEqKYHhPYv8boI1Rc/b47f43e02f04563447fa90d4ff6c8943_9KzW5GTggQ.png",
    id: 2,
    name: "Canary",
  },
  {
    icon: "https://parsefiles.back4app.com/JPaQcFfEEQ1ePBxbf6wvzkPMEqKYHhPYv8boI1Rc/f0b9cdefa67b57eeb080278c2f6984cc_sCqUJBg6Qq.png",
    id: 3,
    name: "Figma",
  },
  {
    icon: "https://parsefiles.back4app.com/JPaQcFfEEQ1ePBxbf6wvzkPMEqKYHhPYv8boI1Rc/178c7b02003c933e6b5afe98bbee595b_low_res_Arc_Browser.png",
    id: 4,
    name: "Arc",
  },
];

export default function AppDownloadStack({
  apps = defaultApps,
  title = "Starter Mac",
  selectedApps: controlledSelected,
  onChange,
  onDownload,
  isExpanded: controlledExpanded,
  onExpandChange,
  className = "",
}: AppDownloadStackProps) {
  const [internalExpanded, setInternalExpanded] = useState(false);
  const [internalSelected, setInternalSelected] = useState<number[]>([]);
  const [isDownloading, setIsDownloading] = useState(false);
  const [downloadComplete, setDownloadComplete] = useState(false);
  const shineControls = useAnimation();
  const shouldReduceMotion = useReducedMotion();

  const isExpanded =
    controlledExpanded === undefined ? internalExpanded : controlledExpanded;
  const selected = controlledSelected ?? internalSelected;

  const setExpanded = (val: boolean) => {
    if (onExpandChange) {
      onExpandChange(val);
    } else {
      setInternalExpanded(val);
    }
  };

  const toggleApp = useCallback(
    (id: number) => {
      const newSelected = selected.includes(id)
        ? selected.filter((appId) => appId !== id)
        : [...selected, id];
      if (onChange) {
        onChange(newSelected);
      } else {
        setInternalSelected(newSelected);
      }
    },
    [selected, onChange]
  );

  const handleDownload = useCallback(() => {
    setIsDownloading(true);
    if (onDownload) {
      onDownload(selected);
    }
    if (!shouldReduceMotion) {
      shineControls.start({
        transition: {
          duration: 1,
          ease: "linear",
          repeat: Number.POSITIVE_INFINITY,
        },
        x: ["0%", "100%"],
      });
    }
    setTimeout(() => {
      shineControls.stop();
      setDownloadComplete(true);
      setTimeout(() => {
        if (onExpandChange) {
          onExpandChange(false);
        } else {
          setInternalExpanded(false);
        }
        if (onChange) {
          onChange([]);
        } else {
          setInternalSelected([]);
        }
        setIsDownloading(false);
        setDownloadComplete(false);
      }, RESET_DELAY_MS);
    }, DOWNLOAD_DURATION_MS);
  }, [
    shineControls,
    selected,
    onDownload,
    onChange,
    onExpandChange,
    shouldReduceMotion,
  ]);

  const stackVariants = useMemo(
    // biome-ignore lint/suspicious/noExplicitAny: Variants type requires flexible return
    (): Record<string, (i: number) => any> => ({
      float: (i: number) =>
        shouldReduceMotion
          ? { y: 0 }
          : {
              transition: {
                y: {
                  delay: i * FLOAT_DELAY_MULTIPLIER,
                  duration: FLOAT_DURATION,
                  ease: [0.645, 0.045, 0.355, 1],
                  repeat: Number.POSITIVE_INFINITY,
                },
              },
              y: [0, -FLOAT_AMPLITUDE, 0],
            },
      hover: (i: number) =>
        shouldReduceMotion
          ? {
              rotate: 0,
              x: 0,
              y: 0,
              zIndex: BASE_Z_INDEX - i * Z_INDEX_STEP,
            }
          : {
              rotate: 0,
              x: i * HOVER_X_MULTIPLIER,
              y: -i * HOVER_Y_MULTIPLIER,
              zIndex: BASE_Z_INDEX - i * Z_INDEX_STEP,
            },
      initial: (i: number) =>
        shouldReduceMotion
          ? {
              rotate: 0,
              x: 0,
              y: 0,
              zIndex: BASE_Z_INDEX - i * Z_INDEX_STEP,
            }
          : {
              rotate:
                i % 2 === 0
                  ? -ROTATION_MULTIPLIER * (i + 1)
                  : ROTATION_MULTIPLIER * (i + 1),
              x:
                i % 2 === 0
                  ? -TRANSLATION_MULTIPLIER * (i + 1)
                  : TRANSLATION_MULTIPLIER * (i + 1),
              y: 0,
              zIndex: BASE_Z_INDEX - i * Z_INDEX_STEP,
            },
    }),
    [shouldReduceMotion]
  );

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

          {isExpanded && !isDownloading && (
            <motion.div
              animate={
                shouldReduceMotion ? { opacity: 1 } : { opacity: 1, scale: 1 }
              }
              className="flex flex-col items-center gap-2"
              exit={
                shouldReduceMotion
                  ? { opacity: 0, transition: { duration: 0 } }
                  : { opacity: 0, scale: 0.8 }
              }
              initial={
                shouldReduceMotion ? { opacity: 1 } : { opacity: 0, scale: 0.8 }
              }
              key="app-selector"
              layout={!shouldReduceMotion}
            >
              <button
                className="flex w-full cursor-pointer items-center justify-between px-0.5"
                onClick={() => setExpanded(false)}
                type="button"
              >
                <p className="my-0 font-medium leading-0">{title}</p>
                <div className="flex items-center gap-1">
                  <p className="my-0 font-medium leading-0">
                    {selected.length}
                  </p>
                  <ChevronDown className="text-mauve-11" size={16} />
                </div>
              </button>
              <motion.ul className="grid grid-cols-2 gap-3">
                {apps.map((app, index) => (
                  <motion.li
                    animate={
                      shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 }
                    }
                    className="relative flex h-[80px] w-[80px]"
                    initial={
                      shouldReduceMotion
                        ? { opacity: 1 }
                        : { opacity: 0, y: 20 }
                    }
                    key={app.id}
                    transition={
                      shouldReduceMotion
                        ? { duration: 0 }
                        : {
                            delay: index * STAGGER_DELAY_MULTIPLIER,
                            duration: 0.25,
                          }
                    }
                  >
                    <div
                      className={`pointer-events-none absolute top-2 right-2 flex h-4 w-4 items-center justify-center rounded-full border border-solid ${
                        selected.includes(app.id)
                          ? "border-blue-500 bg-blue-500"
                          : "border-white/60"
                      }`}
                    >
                      {selected.includes(app.id) && (
                        <motion.svg
                          animate={{ pathLength: 1 }}
                          className="z-1 h-3 w-3"
                          fill="none"
                          initial={{ pathLength: 0 }}
                          stroke="white"
                          strokeLinecap="round"
                          strokeLinejoin="round"
                          strokeWidth="2"
                          transition={{
                            duration: CHECKMARK_TRANSITION_DURATION,
                          }}
                          viewBox="0 0 24 24"
                          xmlns="http://www.w3.org/2000/svg"
                        >
                          <title>Checkmark</title>
                          <motion.path d="M5 13l4 4L19 7" />
                        </motion.svg>
                      )}
                    </div>
                    <button
                      className={`group flex h-full w-full flex-col items-center justify-center gap-1 rounded-xl border-2 border-transparent bg-background/80 p-2 transition-all duration-200 hover:border-blue-500 ${
                        selected.includes(app.id)
                          ? "border-blue-500 bg-blue-500/10"
                          : ""
                      }`}
                      onClick={() => toggleApp(app.id)}
                      type="button"
                    >
                      <img
                        alt={app.name}
                        className="rounded-lg"
                        draggable={false}
                        height={40}
                        src={app.icon}
                        width={40}
                      />
                      <span className="font-medium text-foreground text-xs">
                        {app.name}
                      </span>
                    </button>
                  </motion.li>
                ))}
              </motion.ul>
              <button
                className="mt-4 w-full rounded-lg bg-blue-500 px-4 py-2 font-semibold text-white shadow transition hover:bg-blue-600 disabled:opacity-50"
                disabled={selected.length === 0}
                onClick={handleDownload}
                type="button"
              >
                Download Selected
              </button>
            </motion.div>
          )}

          {isDownloading && !downloadComplete && (
            <motion.div
              animate={
                shouldReduceMotion ? { opacity: 1 } : { opacity: 1, scale: 1 }
              }
              className="flex flex-col items-center gap-4"
              exit={
                shouldReduceMotion
                  ? { opacity: 0, transition: { duration: 0 } }
                  : { opacity: 0, scale: 0.8 }
              }
              initial={
                shouldReduceMotion ? { opacity: 1 } : { opacity: 0, scale: 0.8 }
              }
              key="downloading"
              layout={!shouldReduceMotion}
            >
              <div className="relative flex h-16 w-16 items-center justify-center">
                <motion.div
                  animate={shineControls}
                  className="absolute inset-0 rounded-xl bg-blue-500/20"
                  style={{ x: 0 }}
                />
                {apps.map((app, index) => (
                  <motion.img
                    alt={`${app.name} Logo`}
                    animate={
                      shouldReduceMotion ? "initial" : ["initial", "float"]
                    }
                    className="absolute inset-0 rounded-xl border-none"
                    custom={index}
                    draggable={false}
                    height={64}
                    initial="initial"
                    key={app.id}
                    layoutId={
                      shouldReduceMotion ? undefined : `app-icon-${app.id}`
                    }
                    src={app.icon}
                    transition={
                      shouldReduceMotion
                        ? { duration: 0 }
                        : { duration: TRANSITION_DURATION }
                    }
                    variants={stackVariants}
                    width={64}
                  />
                ))}
              </div>
              <span className="font-semibold text-blue-500">
                Downloading...
              </span>
            </motion.div>
          )}

          {downloadComplete ? (
            <motion.div
              animate={
                shouldReduceMotion ? { opacity: 1 } : { opacity: 1, scale: 1 }
              }
              className="flex flex-col items-center gap-4"
              exit={
                shouldReduceMotion
                  ? { opacity: 0, transition: { duration: 0 } }
                  : { opacity: 0, scale: 0.8 }
              }
              initial={
                shouldReduceMotion ? { opacity: 1 } : { opacity: 0, scale: 0.8 }
              }
              key="download-complete"
              layout={!shouldReduceMotion}
            >
              <span className="font-semibold text-green-500">
                Download Complete!
              </span>
            </motion.div>
          ) : null}
        </AnimatePresence>
      </motion.div>
    </div>
  );
}

Similar components