shadimage
bjork-ui
componentaudio-upload-cardsource: ok · hit

Audio upload card

Audio upload card is a production-ready cards component from BJORK UI, packaged for shadcn registries.

npx shadcn add "https://ui.isaiahbjork.com/audio-upload-card.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
animationframer-motion
iconslucide-react

Files3 · source cached 10/9/2026, 12:06:02 PM

components/bjork-ui/cards/audio-upload-card.tsx
"use client";

import React, { useState, useEffect, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { cn } from "@/lib/utils";
import { Upload, X } from "lucide-react";
import { useTheme } from "next-themes";
import { BjorkCard } from "@/components/bjork-ui/primitives/card";

interface AudioUploadCardProps {
  className?: string;
  triggerAnimation?: boolean;
  onAnimationComplete?: () => void;
  title?: string;
  description?: string;
  tone?: "dark" | "light";
}

interface WaveformProps {
  width?: number;
  height?: number;
  bars?: number;
}

interface UploadCardBaseProps {
  children?: React.ReactNode;
  className?: string;
  isDragOver?: boolean;
  isUploading?: boolean;
  palette: UploadPalette;
}

const uploadPalettes = {
  dark: {
    card: "border-[#161616] bg-[#121212] text-[#ededed]",
    drop: "bg-[#090909] shadow-[inset_0_12px_24px_rgba(0,0,0,0.45),inset_0_1px_0_rgba(255,255,255,0.035)]",
    dropHover:
      "hover:bg-[#0c0c0c] hover:shadow-[inset_0_16px_30px_rgba(0,0,0,0.56),inset_0_1px_0_rgba(255,255,255,0.05),inset_0_0_0_1px_rgba(255,255,255,0.025),0_10px_22px_-18px_rgba(0,0,0,0.9)]",
    dropDrag: "bg-[#140f0b] shadow-[inset_0_12px_24px_rgba(0,0,0,0.42),inset_0_0_0_1px_rgba(236,92,19,0.18)]",
    dropUploading: "bg-[#160f0b] shadow-[inset_0_12px_24px_rgba(0,0,0,0.42),inset_0_0_0_1px_rgba(236,92,19,0.14)]",
    dash: "border-[#2a2a2a]",
    dashActive: "border-[#bd4514]/70",
    icon: "text-[#464646]",
    iconActive: "text-[#d86a2c]",
    fileShell:
      "border border-[#242424] bg-[#121212] text-[#ededed] shadow-[inset_0_7px_14px_rgba(255,255,255,0.035),0_18px_34px_-16px_rgba(0,0,0,0.9)]",
    remove: "bg-[#2a1711] text-[#ffb087] hover:bg-[#3a1e14]",
    title: "text-[#ededed]",
    description: "text-[#ededed]/46",
    filename: "text-[#ededed]/58",
    waveform: "text-[#ededed]/44",
  },
  light: {
    card:
      "border-[#f5ede2] bg-[#fffcf6] text-[#171717] shadow-[inset_0_7px_14px_rgba(88,72,49,0.045),inset_0_0.5px_0.5px_rgba(255,255,255,0.92),inset_1px_0_0_rgba(88,72,49,0.026),inset_-1px_0_0_rgba(255,255,255,0.68),0_14px_22px_-9px_rgba(66,52,33,0.11)]",
    drop: "bg-[#fbf8f1] shadow-[inset_0_12px_24px_rgba(88,72,49,0.03),inset_0_1px_0_rgba(255,255,255,0.92)]",
    dropHover:
      "hover:bg-[#fffaf2] hover:shadow-[inset_0_16px_30px_rgba(88,72,49,0.04),inset_0_1px_0_rgba(255,255,255,0.94),inset_0_0_0_1px_rgba(88,72,49,0.026)]",
    dropDrag: "bg-[#fff5eb] shadow-[inset_0_12px_24px_rgba(88,72,49,0.045),inset_0_0_0_1px_rgba(236,92,19,0.16)]",
    dropUploading: "bg-[#fff4ea] shadow-[inset_0_12px_24px_rgba(88,72,49,0.045),inset_0_0_0_1px_rgba(236,92,19,0.14)]",
    dash: "border-[#e4dbcd]",
    dashActive: "border-[#bd4514]/65",
    icon: "text-[#a49b8e]",
    iconActive: "text-[#bd4514]",
    fileShell:
      "border border-[#f5ede2] bg-[#fffcf6] text-[#171717] shadow-[inset_0_7px_14px_rgba(88,72,49,0.045),inset_0_0.5px_0.5px_rgba(255,255,255,0.92),0_14px_22px_-9px_rgba(66,52,33,0.11)]",
    remove: "bg-[#2a1711] text-[#ffb087] hover:bg-[#3a1e14]",
    title: "text-[#171717]",
    description: "text-[#171717]/60",
    filename: "text-[#171717]/60",
    waveform: "text-[#171717]/60",
  },
};

type UploadPalette = (typeof uploadPalettes)["dark"];

const Waveform = ({ width = 300, height = 40, bars = 60 }: WaveformProps) => {
  const [barsArray, setBarsArray] = useState<React.ReactElement[]>([]);

  useEffect(() => {
    const barWidth = (width / bars) * 0.5; // Thinner bars (60% of available space)
    const spacing = (width / bars) * 0.4; // Spacing between bars
    const centerY = height / 2;
    const cornerRadius = barWidth * 0.7; // Rounded corners

    const newBarsArray = [];

    // Calculate total width of all bars and spacing
    const totalBarsWidth = (barWidth + spacing) * bars - spacing; // Subtract last spacing
    const startX = (width - totalBarsWidth) / 2; // Center the waveform

    for (let i = 0; i < bars; i++) {
      const x = startX + i * (barWidth + spacing);
      const barHeight = Math.random() * (height * 0.6) + height * 0.1; // Random height between 10% and 70% of total height
      const topY = centerY - barHeight / 2;

      newBarsArray.push(
        <rect
          key={i}
          x={x}
          y={topY}
          width={barWidth}
          height={barHeight}
          rx={cornerRadius}
          ry={cornerRadius}
          fill="currentColor"
        />
      );
    }

    setBarsArray(newBarsArray);
  }, [width, height, bars]);

  return (
    <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
      {barsArray}
    </svg>
  );
};

const UploadCardBase = ({
  children,
  className,
  isDragOver = false,
  isUploading = false,
  palette,
}: UploadCardBaseProps) => {
  const hasChildren = React.Children.count(children) > 0;

  return (
    <div className="relative">
      <div
        className={cn(
          "relative z-0 flex min-h-[130px] items-center justify-center rounded-xl p-6 backdrop-blur-sm transition-[background-color,box-shadow] duration-200",
          !isUploading && cn("cursor-pointer", palette.dropHover),
          isUploading
            ? palette.dropUploading
            : isDragOver
              ? palette.dropDrag
              : palette.drop,
          className
        )}
      >
        {/* Upload icon in background - only shows when no children */}
        {!hasChildren && (
          <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
            <Upload
              size={48}
              className={cn(
                "transition-colors duration-200",
                isDragOver || isUploading ? palette.iconActive : palette.icon
              )}
            />
          </div>
        )}

        {/* Content layer - above the background icon */}
        <div className="relative z-10 w-full">
          {children}
        </div>
      </div>

      <div
        className={cn(
          "pointer-events-none absolute inset-0 z-20 rounded-xl border-2 border-dashed",
          isUploading || isDragOver ? palette.dashActive : palette.dash
        )}
      />
    </div>
  );
};

interface AudioComponentProps {
  isAnimating: boolean;
  onAnimationComplete?: () => void;
  filename?: string;
  onRemove?: () => void;
  palette: UploadPalette;
}

// Utility function to truncate filename
const truncateFilename = (filename: string, maxLength: number = 20) => {
  if (filename.length <= maxLength) return filename;
  const extension = filename.split('.').pop();
  const nameWithoutExt = filename.replace(`.${extension}`, '');
  const truncatedName = nameWithoutExt.substring(0, maxLength - 3 - extension!.length);
  return `${truncatedName}...${extension}`;
};

const AudioComponent = ({
  isAnimating,
  onAnimationComplete,
  filename = "audio.mp3",
  onRemove,
  palette,
}: AudioComponentProps) => {
  const [isRemoving, setIsRemoving] = useState(false);
  const [shouldShow, setShouldShow] = useState(false);

  // Update shouldShow when isAnimating changes
  useEffect(() => {
    if (isAnimating) {
      setShouldShow(true);
    }
  }, [isAnimating]);

  // Don't render if we shouldn't show and we're not removing
  if (!shouldShow && !isRemoving) return null;

  const displayName = truncateFilename(filename);

  const handleRemove = () => {
    setIsRemoving(true);
  };

  const handleRemoveComplete = () => {
    setShouldShow(false);
    setIsRemoving(false);
    onRemove?.();
  };

  return (
    <AnimatePresence>
      {shouldShow && (
        <motion.div
          className="absolute z-20"
          initial={{
            // Start at bottom-right corner of the main card
            right: 20,
            bottom: 20,
            opacity: 0,
          }}
          animate={isRemoving ? {
            scale: 0,
            opacity: 0,
            filter: "blur(8px)",
            transition: {
              duration: 0.4,
              ease: [0.23, 1, 0.32, 1],
            }
          } : {
            // Move quickly from bottom-right to center  
            left: "50%",
            top: "calc(50% - 15px)", // Dynamic center of upload area
            x: "-50%",
            y: "-50%",
            opacity: 1,
            transition: {
              duration: 0.6, // Much faster
              ease: [0.23, 1, 0.32, 1],
            },
          }}
          exit={{
            scale: 0,
            opacity: 0,
            filter: "blur(8px)",
            transition: {
              duration: 0.4,
              ease: [0.23, 1, 0.32, 1],
            }
          }}
          style={{
            transformOrigin: "center",
          }}
          onAnimationComplete={isRemoving ? handleRemoveComplete : onAnimationComplete}
        >
          <motion.div 
            initial={{ scale: 1.5 }}
            animate={
              isRemoving
                ? {
                    scale: 0,
                    transition: { duration: 0.4 },
                  }
                : {
                    scale: [1.5, 1.1, 1],
                    transition: {
                      duration: 0.8,
                      ease: [0.68, -0.55, 0.265, 1.55],
                      times: [0, 0.35, 1],
                    },
                  }
            }
            className={cn(
              "relative group rounded-lg px-2 py-1.5 backdrop-blur-sm",
              palette.fileShell
            )}
          >
            {/* X button */}
            <button
              onClick={handleRemove}
              className={cn(
                "absolute -right-2 -top-2 z-30 flex h-5 w-5 items-center justify-center rounded-full opacity-0 transition duration-200 hover:scale-110 group-hover:opacity-100",
                palette.remove
              )}
            >
              <X size={12} />
            </button>

            <div className={cn("flex w-full items-center justify-center", palette.waveform)}>
              <Waveform width={180} height={32} bars={40} />
            </div>
            <div className="">
              <span className={cn("text-left text-xs font-medium", palette.filename)}>
                {displayName}
              </span>
            </div>
          </motion.div>
        </motion.div>
      )}
    </AnimatePresence>
  );
};

export function AudioUploadCard({
  className,
  triggerAnimation = false,
  onAnimationComplete,
  title = "Upload Your Audio",
  description = "Drop in your recordings and start transcribing instantly.",
  tone,
}: AudioUploadCardProps) {
  const { resolvedTheme } = useTheme();
  const [mounted, setMounted] = useState(false);
  const [isAnimating, setIsAnimating] = useState(false);
  const [isDragOver, setIsDragOver] = useState(false);
  const [isUploading, setIsUploading] = useState(false);
  const [uploadedFile, setUploadedFile] = useState<File | null>(null);
  const fileInputRef = React.useRef<HTMLInputElement>(null);
  const palette =
    tone === "light" || (!tone && mounted && resolvedTheme === "light")
      ? uploadPalettes.light
      : uploadPalettes.dark;

  useEffect(() => {
    setMounted(true);
  }, []);

  useEffect(() => {
    if (triggerAnimation) {
      setIsAnimating(true);
    }
  }, [triggerAnimation]);

  const handleAnimationComplete = () => {
    // Don't automatically stop the animation - keep it visible
    onAnimationComplete?.();
  };

  const handleDragOver = useCallback((e: React.DragEvent) => {
    e.preventDefault();
    e.stopPropagation();
    setIsDragOver(true);
  }, []);

  const handleDragLeave = useCallback((e: React.DragEvent) => {
    e.preventDefault();
    e.stopPropagation();
    setIsDragOver(false);
  }, []);

  const handleDrop = useCallback((e: React.DragEvent) => {
    e.preventDefault();
    e.stopPropagation();
    setIsDragOver(false);

    const files = Array.from(e.dataTransfer.files);
    const audioFile = files.find(file => file.type.startsWith('audio/'));

    if (audioFile) {
      setUploadedFile(audioFile);
      setIsUploading(true);

      // Simulate upload process
      setTimeout(() => {
        setIsUploading(false);
        setIsAnimating(true); // Trigger animation after upload
      }, 200); // Much faster upload simulation
    }
  }, []);

  const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (file && file.type.startsWith('audio/')) {
      setUploadedFile(file);
      setIsUploading(true);

      // Simulate upload process
      setTimeout(() => {
        setIsUploading(false);
        setIsAnimating(true); // Trigger animation after upload
      }, 200); // Much faster upload simulation
    }
  }, []);

  const handleRemoveFile = useCallback(() => {
    setUploadedFile(null);
    setIsAnimating(false);
    // Reset the file input so it can trigger onChange again for the same file
    if (fileInputRef.current) {
      fileInputRef.current.value = '';
    }
  }, []);

  const handleBaseClick = useCallback(() => {
    if (!isUploading && !uploadedFile) {
      fileInputRef.current?.click();
    }
  }, [isUploading, uploadedFile]);
  return (
    <motion.div
      className={cn("relative w-full max-w-md mx-auto", className)}
      initial={tone ? false : { opacity: 0, y: 20 }}
      animate={tone ? undefined : { opacity: 1, y: 0 }}
      transition={tone ? undefined : { type: "spring", stiffness: 300, damping: 30 }}
    >
      <BjorkCard
        variant="elevated"
        padding="none"
        className={cn("relative text-center", palette.card)}
      >
        <div className="flex flex-col justify-center space-y-8 p-6">
          <div className="relative mx-auto w-full">
            <div
              className="relative"
              onDragOver={handleDragOver}
              onDragLeave={handleDragLeave}
              onDrop={handleDrop}
              onClick={handleBaseClick}
            >
              <UploadCardBase
                isDragOver={isDragOver}
                isUploading={isUploading}
                palette={palette}
              />

              {/* Hidden file input for click-to-upload */}
              <input
                ref={fileInputRef}
                type="file"
                accept="audio/*"
                onChange={handleFileSelect}
                className="sr-only"
              />
            </div>
          </div>

          <div className="flex flex-col items-start">
            <h2 className={cn("text-left text-lg font-semibold", palette.title)}>
              {title}
            </h2>

            <p className={cn("text-left text-sm", palette.description)}>
              {description}
            </p>
          </div>
        </div>

        <AudioComponent
          isAnimating={isAnimating}
          onAnimationComplete={handleAnimationComplete}
          filename={uploadedFile?.name}
          onRemove={handleRemoveFile}
          palette={palette}
        />
      </BjorkCard>
    </motion.div>
  );
}
lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}
components/bjork-ui/primitives/card.tsx
"use client";

import * as React from "react";
import { motion, useReducedMotion, type HTMLMotionProps } from "framer-motion";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";

const bjorkCardVariants = cva(
  "relative flex flex-col overflow-hidden rounded-[20px] border text-[color:var(--bjork-text)] outline-none transition-[background-color,border-color,box-shadow,opacity,transform] duration-300 focus-visible:ring-2 focus-visible:ring-[#ec5c13]/45 focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bjork-ring-offset)]",
  {
    variants: {
      variant: {
        surface:
          "border-[color:var(--bjork-border-muted)] bg-[var(--bjork-surface)] shadow-[var(--bjork-shadow-surface)]",
        elevated:
          "border-[color:var(--bjork-border-muted)] bg-[var(--bjork-surface)] shadow-[var(--bjork-shadow-surface)]",
        quiet:
          "border-[color:var(--bjork-border-muted)] bg-[var(--bjork-panel)] shadow-[var(--bjork-shadow-soft)]",
        interactive:
          "cursor-pointer border-[color:var(--bjork-border-muted)] bg-[var(--bjork-surface)] shadow-[var(--bjork-shadow-surface)] hover:border-[color:var(--bjork-border)] hover:bg-[var(--bjork-surface-hover)]",
      },
      padding: {
        none: "p-0",
        sm: "p-3",
        md: "p-4",
        lg: "p-5",
      },
    },
    defaultVariants: {
      variant: "surface",
      padding: "md",
    },
  },
);

export interface BjorkCardProps
  extends Omit<HTMLMotionProps<"div">, "ref">,
    VariantProps<typeof bjorkCardVariants> {}

export function BjorkCard({
  className,
  variant,
  padding,
  ...props
}: BjorkCardProps) {
  const shouldReduceMotion = useReducedMotion();
  const isInteractive = variant === "interactive" || props.role === "button";

  return (
    <motion.div
      data-slot="bjork-card"
      className={cn(bjorkCardVariants({ variant, padding, className }))}
      whileHover={isInteractive && !shouldReduceMotion ? { y: -2, scale: 1.005 } : undefined}
      whileTap={isInteractive && !shouldReduceMotion ? { scale: 0.995 } : undefined}
      transition={{ type: "spring", stiffness: 260, damping: 28, mass: 0.8 }}
      {...props}
    />
  );
}

export function BjorkCardHeader({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="bjork-card-header"
      className={cn("flex items-start justify-between gap-4", className)}
      {...props}
    />
  );
}

export function BjorkCardTitle({
  className,
  ...props
}: React.ComponentProps<"h3">) {
  return (
    <h3
      data-slot="bjork-card-title"
      className={cn("text-base font-semibold leading-none tracking-[-0.02em] text-[color:var(--bjork-text)]", className)}
      {...props}
    />
  );
}

export function BjorkCardDescription({
  className,
  ...props
}: React.ComponentProps<"p">) {
  return (
    <p
      data-slot="bjork-card-description"
      className={cn("text-sm leading-6 text-[color:var(--bjork-text-soft)]", className)}
      {...props}
    />
  );
}

export function BjorkCardContent({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="bjork-card-content"
      className={cn("mt-5 text-[color:var(--bjork-text-medium)]", className)}
      {...props}
    />
  );
}

export function BjorkCardFooter({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="bjork-card-footer"
      className={cn("mt-5 flex items-center gap-3", className)}
      {...props}
    />
  );
}

export { bjorkCardVariants };

More from bjork-ui

Browse all components in this library →