Volume Control

An accessible animated volume control with intuitive visual feedback, perfect for audio and media interfaces.

Preview

Volume Control
3

Installation

Install dependencies

# Install Shadcn Button component
npx shadcn@latest add button
 
# Install Framer Motion
npm install framer-motion
 
# Install Lucide React for icons
npm install lucide-react

Create the component file

mkdir -p components/shsfui/control && touch components/shsfui/control/volume-control.tsx

Add the component code

Open the newly created file and paste the following code:

"use client";
 
import * as React from "react";
import {
  MinusIcon,
  PlusIcon,
  Volume1Icon,
  Volume2Icon,
  VolumeIcon,
  VolumeXIcon,
  LucideIcon,
} from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import { Button } from "@/components/ui/button";
import { cn } from "@/utils/cn";
 
type VolumeControlProps = {
  className?: string;
  initialValue?: number;
  min?: number;
  max?: number;
  onChange?: (value: number) => void;
};
 
const numberVariants = {
  initial: { opacity: 0, y: 20 },
  animate: (opacity: number) => ({ opacity, y: 0 }),
  exit: { opacity: 0, y: -20 },
  transition: { duration: 0.2 },
};
 
const buttonVariants = {
  tap: { scale: 0.9 },
  hover: { scale: 1.05 },
};
 
const VolumeControl = React.forwardRef<HTMLDivElement, VolumeControlProps>(
  (props, ref) => {
    const {
      className,
      initialValue = 3,
      min = 0,
      max = 6,
      onChange,
      ...restProps
    } = props;
 
    const [volume, setVolume] = React.useState(initialValue);
 
    const decreaseVolume = () => {
      if (volume > min) {
        const newValue = volume - 1;
        setVolume(newValue);
        onChange?.(newValue);
      }
    };
 
    const increaseVolume = () => {
      if (volume < max) {
        const newValue = volume + 1;
        setVolume(newValue);
        onChange?.(newValue);
      }
    };
 
    const getVolumeIcon = (): LucideIcon => {
      if (volume === min) return VolumeXIcon;
      if (volume < max / 2) return VolumeIcon;
      if (volume < max * 0.8) return Volume1Icon;
      return Volume2Icon;
    };
 
    const dynamicOpacity = volume === min ? 0.3 : 0.4 + (volume / max) * 0.6;
    const Icon = getVolumeIcon();
 
    return (
      <div
        ref={ref}
        className={cn("inline-flex items-center", className)}
        role="group"
        aria-labelledby="volume-control"
        {...restProps}
      >
        <span id="volume-control" className="sr-only">
          Volume Control
        </span>
 
        <motion.div variants={buttonVariants} whileHover="hover" whileTap="tap">
          <Button
            className="rounded-full"
            variant="outline"
            size="icon"
            aria-label="Decrease volume"
            onClick={decreaseVolume}
            disabled={volume === min}
            type="button"
          >
            <MinusIcon size={16} aria-hidden="true" />
          </Button>
        </motion.div>
 
        <div
          className="flex items-center px-4 text-sm font-medium"
          aria-live="polite"
        >
          <div className="flex items-center">
            <motion.div
              key={`icon-${volume}`}
              initial={{ opacity: dynamicOpacity }}
              animate={{ opacity: dynamicOpacity }}
              transition={{ duration: 0.2 }}
              className="mr-2"
            >
              <Icon size={16} aria-hidden="true" />
            </motion.div>
 
            <div className="relative h-6 w-4 overflow-hidden">
              <AnimatePresence mode="popLayout" initial={false}>
                <motion.span
                  key={volume}
                  className="absolute inset-0 flex items-center justify-center"
                  custom={dynamicOpacity}
                  variants={numberVariants}
                  initial="initial"
                  animate="animate"
                  exit="exit"
                  aria-label={`Current volume is ${volume}`}
                >
                  {volume}
                </motion.span>
              </AnimatePresence>
            </div>
          </div>
        </div>
 
        <motion.div variants={buttonVariants} whileHover="hover" whileTap="tap">
          <Button
            className="rounded-full"
            variant="outline"
            size="icon"
            aria-label="Increase volume"
            onClick={increaseVolume}
            disabled={volume === max}
            type="button"
          >
            <PlusIcon size={16} aria-hidden="true" />
          </Button>
        </motion.div>
      </div>
    );
  }
);
 
VolumeControl.displayName = "VolumeControl";
 
export VolumeControl;