Skip to contentVibraUI
Utilities

Copy button

An icon button that copies a value and confirms it with a check and a tooltip.

The tooltip only reaches a pointer, so the button also renames itself to the success label and announces it in a polite live region. Every prop the Button takes passes through, so variant and size can be overridden for toolbars and input groups.

Install

npx shadcn@latest add @vibra/copy-button

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
valuestring—The text written to the clipboard, in full, even when the UI around it shows a masked form.
labelstring"Copy"Names the button and fills the tooltip before the copy.
successLabelstring"Copied"Names the button, fills the tooltip, and is announced once the copy lands.
timeoutnumber1500Milliseconds the confirmation stays up before the button returns to its copy state.
variantButton variant"ghost"Passed straight to the underlying Button.
sizeButton size"icon"Passed straight to the underlying Button; icon-xs suits an input group.

Dependencies

Source

components/ui/copy-button.tsx
"use client"

import * as React from "react"
import { CheckIcon, CopyIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"

export type CopyButtonProps = Omit<React.ComponentProps<typeof Button>, "value"> & {
  /** The text written to the clipboard — the full value, even when the UI around it shows a masked one. */
  value: string
  label?: string
  successLabel?: string
  /** Milliseconds the confirmation stays up before the button returns to its copy state. */
  timeout?: number
}

function CopyButton({
  className,
  value,
  label = "Copy",
  successLabel = "Copied",
  timeout = 1500,
  variant = "ghost",
  size = "icon",
  onClick,
  ...props
}: CopyButtonProps) {
  const { copied, copy } = useCopyToClipboard(timeout)
  const name = copied ? successLabel : label

  return (
    <>
      <Tooltip>
        <TooltipTrigger
          render={
            <Button
              type="button"
              data-slot="copy-button"
              data-state={copied ? "copied" : "idle"}
              variant={variant}
              size={size}
              // The icon carries no text, so the button is named outright —
              // and renamed once the copy lands.
              aria-label={name}
              className={cn("text-muted-foreground", className)}
              onClick={(event) => {
                onClick?.(event)
                void copy(value)
              }}
              {...props}
            >
              {copied ? (
                <CheckIcon data-slot="copy-button-check-icon" className="text-success" />
              ) : (
                <CopyIcon data-slot="copy-button-copy-icon" />
              )}
            </Button>
          }
        />
        <TooltipContent>{name}</TooltipContent>
      </Tooltip>
      {/* The tooltip only reaches a pointer. This is what a screen reader
          hears when the copy succeeds. */}
      <span data-slot="copy-button-status" role="status" aria-live="polite" className="sr-only">
        {copied ? successLabel : ""}
      </span>
    </>
  )
}

export { CopyButton }