Tiesen Logo
Components

Progress Button

A button component that shows progress and can be used for actions like form submission.

GitHubComponent Source
'use client'

import * as React from 'react'

import { ProgressButton } from '@/components/ui/progress-button'

export default function ProgressButtonDemo() {
  const [progress, setProgress] = React.useState(0)

  React.useEffect(() => {
    const interval = setInterval(() => {
      setProgress((prev) => {
        if (prev >= 100) {
          clearInterval(interval)
          return 100
        }
        return prev + 10
      })
    }, 200)

    return () => {
      clearInterval(interval)
    }
  }, [progress])

  return (
    <div className='flex gap-4'>
      {(
        ['default', 'secondary', 'destructive', 'outline', 'ghost'] as const
      ).map((variant) => (
        <ProgressButton
          key={variant}
          variant={variant}
          progress={progress}
          isLoading={progress < 100}
          onClick={() => {
            setProgress(0)
          }}
        >
          Submit
        </ProgressButton>
      ))}
    </div>
  )
}

Installation

CLI

npx shadcn add https://ui.tiesen.id.vn/r/progress-button.json
npx shadcn add https://ui.tiesen.id.vn/r/progress-button.json
pnpm dlx shadcn add https://ui.tiesen.id.vn/r/progress-button.json
bunx --bun shadcn add https://ui.tiesen.id.vn/r/progress-button.json

Manual

Copy and paste the following code into your project.

import * as React from 'react'

import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'

interface ProgressButtonProps extends React.ComponentProps<typeof Button> {
  progress: number
  minProgress?: number
  maxProgress?: number
  isLoading?: boolean
}

function ProgressButton({
  progress,
  minProgress = 0,
  maxProgress = 100,
  isLoading = false,
  className,
  children,
  onMouseEnter,
  onMouseLeave,
  ...props
}: ProgressButtonProps) {
  const { normalizedProgress, progressPercentage } = React.useMemo(() => {
    const normalizedProgress = Math.min(
      Math.max(progress, minProgress),
      maxProgress,
    )
    const progressPercentage =
      ((normalizedProgress - minProgress) / (maxProgress - minProgress)) * 100
    return { normalizedProgress, progressPercentage }
  }, [progress, minProgress, maxProgress])

  const style = React.useMemo(() => {
    if (props.variant === 'destructive')
      return {
        '--default': 'var(--destructive)',
        '--active': 'var(--default)',
        '--hover': 'color-mix(in oklab, var(--destructive) 90%, transparent)',
        background: `linear-gradient(to right, var(--active) ${progressPercentage}%, var(--secondary) ${progressPercentage}%)`,
      }
    else if (props.variant === 'secondary')
      return {
        '--default': 'var(--secondary)',
        '--hover': 'color-mix(in oklab, var(--secondary) 90%, transparent)',
        '--active': 'var(--default)',
        background: `linear-gradient(to right, var(--active) ${progressPercentage}%, var(--primary) ${progressPercentage}%)`,
      }
    else if (props.variant === 'outline')
      return {
        '--default': 'var(--background)',
        '--hover': 'var(--accent)',
        '--active': 'var(--default)',
        background: `linear-gradient(to right, var(--active) ${progressPercentage}%, var(--accent) ${progressPercentage}%)`,
      }
    else if (props.variant === 'ghost')
      return {
        '--default': 'transparent',
        '--hover': 'var(--accent)',
        '--active': ' var(--default)',
        background: `linear-gradient(to right, var(--active) ${progressPercentage}%, var(--accent) ${progressPercentage}%)`,
      }
    return {
      '--default': 'var(--primary)',
      '--hover': 'color-mix(in oklab, var(--primary) 90%, transparent)',
      '--active': 'var(--default)',
      background: `linear-gradient(to right, var(--active) ${progressPercentage}%, var(--secondary) ${progressPercentage}%)`,
    }
  }, [progressPercentage, props.variant])

  const handleMouseEnter = React.useCallback(
    (e: React.MouseEvent<HTMLButtonElement>) => {
      e.currentTarget.style.setProperty('--active', 'var(--hover)')
      onMouseEnter?.(e)
    },
    [onMouseEnter],
  )

  const handleMouseLeave = React.useCallback(
    (e: React.MouseEvent<HTMLButtonElement>) => {
      e.currentTarget.style.setProperty('--active', 'var(--default)')
      onMouseLeave?.(e)
    },
    [onMouseLeave],
  )

  return (
    <Button
      {...props}
      data-slot='loading-button'
      role='progressbar'
      disabled={isLoading}
      style={style}
      className={cn(
        'group/loading-button relative transition-colors',
        'disabled:text-transparent disabled:opacity-100',
        className,
      )}
      aria-busy={isLoading}
      aria-valuemin={minProgress}
      aria-valuemax={maxProgress}
      aria-valuenow={normalizedProgress}
      aria-label={
        isLoading ? `Progress: ${Math.round(progressPercentage)}%` : undefined
      }
      onMouseEnter={handleMouseEnter}
      onMouseLeave={handleMouseLeave}
    >
      {children}
      <span className='absolute inset-0 hidden items-center justify-center text-sm font-medium text-background mix-blend-difference group-disabled/loading-button:flex dark:text-foreground'>
        {Math.round(progressPercentage)}%
      </span>
    </Button>
  )
}

export { ProgressButton }