Shadcn Hooks

useClipboard

A hook to copy text to clipboard

Loading...

Installation

npx shadcn@latest add @hooks/use-clipboard
pnpm dlx shadcn@latest add @hooks/use-clipboard
yarn dlx shadcn@latest add @hooks/use-clipboard
bun x shadcn@latest add @hooks/use-clipboard

Copy and paste the following code into your project.

use-event-listener.ts
import { useEffectWithTarget } from '@/registry/hooks/use-effect-with-target'
import { useLatest } from '@/registry/hooks/use-latest'
import { getTargetElement } from '@/registry/lib/create-effect-with-target'
import type { BasicTarget } from '@/registry/lib/create-effect-with-target'

type noop = (...p: any) => void

export type Target = BasicTarget<HTMLElement | Element | Window | Document>

interface Options<T extends Target = Target> {
  target?: T
  capture?: boolean
  once?: boolean
  passive?: boolean
  enable?: boolean
}

function useEventListener<K extends keyof HTMLElementEventMap>(
  eventName: K,
  handler: (ev: HTMLElementEventMap[K]) => void,
  options?: Options<HTMLElement>,
): void
function useEventListener<K extends keyof ElementEventMap>(
  eventName: K,
  handler: (ev: ElementEventMap[K]) => void,
  options?: Options<Element>,
): void
function useEventListener<K extends keyof DocumentEventMap>(
  eventName: K,
  handler: (ev: DocumentEventMap[K]) => void,
  options?: Options<Document>,
): void
function useEventListener<K extends keyof WindowEventMap>(
  eventName: K,
  handler: (ev: WindowEventMap[K]) => void,
  options?: Options<Window>,
): void
function useEventListener(
  eventName: string | string[],
  handler: (event: Event) => void,
  options?: Options<Window>,
): void
function useEventListener(
  eventName: string | string[],
  handler: noop,
  options: Options,
): void

function useEventListener(
  eventName: string | string[],
  handler: noop,
  options: Options = {},
) {
  const { enable = true } = options

  const handlerRef = useLatest(handler)

  useEffectWithTarget(
    () => {
      if (!enable) {
        return
      }

      const targetElement = getTargetElement(options.target, window)
      if (!targetElement?.addEventListener) {
        return
      }

      const eventListener = (event: Event) => {
        return handlerRef.current(event)
      }

      const eventNameArray = Array.isArray(eventName) ? eventName : [eventName]

      eventNameArray.forEach((event) => {
        targetElement.addEventListener(event, eventListener, {
          capture: options.capture,
          once: options.once,
          passive: options.passive,
        })
      })

      return () => {
        eventNameArray.forEach((event) => {
          targetElement.removeEventListener(event, eventListener, {
            capture: options.capture,
          })
        })
      }
    },
    [eventName, options.capture, options.once, options.passive, enable],
    options.target,
  )
}

export { useEventListener }

use-memoized-fn.ts
import { useMemo, useRef } from 'react'

type noop = (this: any, ...args: any[]) => any

type PickFunction<T extends noop> = (
  this: ThisParameterType<T>,
  ...args: Parameters<T>
) => ReturnType<T>

export function useMemoizedFn<T extends noop>(fn: T) {
  const fnRef = useRef<T>(fn)

  // why not write `fnRef.current = fn`?
  // https://github.com/alibaba/hooks/issues/728
  fnRef.current = useMemo<T>(() => fn, [fn])

  const memoizedFn = useRef<PickFunction<T>>(undefined)

  if (!memoizedFn.current) {
    memoizedFn.current = function (this, ...args) {
      return fnRef.current.apply(this, args)
    }
  }

  return memoizedFn.current
}

use-unmount.ts
import { useEffect } from 'react'
import { useLatest } from '@/registry/hooks/use-latest'

export function useUnmount(fn: () => void) {
  const fnRef = useLatest(fn)

  useEffect(
    () => () => {
      fnRef.current()
    },
    [],
  )
}

is-browser.ts
/**
 * A library that checks if the code is running in a browser.
 */
export const isBrowser = typeof window !== 'undefined'

use-clipboard.ts
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEventListener } from '@/registry/hooks/use-event-listener'
import { useMemoizedFn } from '@/registry/hooks/use-memoized-fn'
import { useUnmount } from '@/registry/hooks/use-unmount'
import { isBrowser } from '@/registry/lib/is-browser'

export interface UseClipboardOptions {
  /**
   * Enabled reading for clipboard
   *
   * @default false
   */
  read?: boolean

  /**
   * Copy source
   */
  source?: string

  /**
   * Milliseconds to reset state of `copied` ref
   *
   * @default 1500
   */
  copiedDuring?: number

  /**
   * Whether fallback to document.execCommand('copy') if clipboard is undefined.
   *
   * @default false
   */
  legacy?: boolean
}

export interface UseClipboardReturn {
  isSupported: boolean
  text: string
  copied: boolean
  copy: (text?: string) => Promise<void>
}

type PermissionState = 'granted' | 'denied' | 'prompt' | undefined

function isAllowed(status: PermissionState): boolean {
  return status === 'granted' || status === 'prompt'
}

function legacyCopy(value: string): void {
  const ta = document.createElement('textarea')
  ta.value = value
  ta.style.position = 'absolute'
  ta.style.opacity = '0'
  ta.setAttribute('readonly', '')
  document.body.appendChild(ta)
  ta.select()
  document.execCommand('copy')
  ta.remove()
}

function legacyRead(): string {
  return document?.getSelection?.()?.toString() ?? ''
}

/**
 * Reactive Clipboard API.
 *
 * @param options - Configuration options
 * @returns Clipboard state and methods
 */
export function useClipboard(
  options: UseClipboardOptions = {},
): UseClipboardReturn {
  const { read = false, source, copiedDuring = 1500, legacy = false } = options

  const [text, setText] = useState<string>('')
  const [copied, setCopied] = useState<boolean>(false)
  const [permissionRead, setPermissionRead] =
    useState<PermissionState>(undefined)
  const [permissionWrite, setPermissionWrite] =
    useState<PermissionState>(undefined)

  const timeoutRef = useRef<number | null>(null)

  const isClipboardApiSupported = useMemo(() => {
    if (!isBrowser) return false
    return 'clipboard' in navigator
  }, [])

  const isSupported = useMemo(() => {
    return isClipboardApiSupported || legacy
  }, [isClipboardApiSupported, legacy])

  // Check permissions
  useEffect(() => {
    if (!isBrowser || !isClipboardApiSupported) return

    const checkPermissions = async () => {
      try {
        if ('permissions' in navigator) {
          const readPermission = await navigator.permissions.query({
            name: 'clipboard-read' as PermissionName,
          })
          setPermissionRead(readPermission.state)
          readPermission.onchange = () => {
            setPermissionRead(readPermission.state)
          }

          const writePermission = await navigator.permissions.query({
            name: 'clipboard-write' as PermissionName,
          })
          setPermissionWrite(writePermission.state)
          writePermission.onchange = () => {
            setPermissionWrite(writePermission.state)
          }
        }
      } catch {
        // Permissions API might not be supported or clipboard permissions might not be queryable
        // In this case, we'll try to use the clipboard API directly
      }
    }

    checkPermissions()
  }, [isClipboardApiSupported])

  const updateText = useMemoizedFn(async () => {
    let useLegacy = !(isClipboardApiSupported && isAllowed(permissionRead))
    if (!useLegacy) {
      try {
        const clipboardText = await navigator.clipboard.readText()
        setText(clipboardText)
      } catch {
        useLegacy = true
      }
    }
    if (useLegacy) {
      setText(legacyRead())
    }
  })

  // Listen to copy/cut events if read is enabled
  useEventListener(isSupported && read ? ['copy', 'cut'] : [], updateText, {
    passive: true,
    enable: isSupported && read,
  })

  const copy = useMemoizedFn(async (value?: string) => {
    const textToCopy = value ?? source
    if (!isSupported || textToCopy == null) return

    let useLegacy = !(isClipboardApiSupported && isAllowed(permissionWrite))
    if (!useLegacy) {
      try {
        await navigator.clipboard.writeText(textToCopy)
      } catch {
        useLegacy = true
      }
    }
    if (useLegacy) {
      legacyCopy(textToCopy)
    }

    setText(textToCopy)
    setCopied(true)

    // Clear existing timeout
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current)
    }

    // Set new timeout
    timeoutRef.current = window.setTimeout(() => {
      setCopied(false)
      timeoutRef.current = null
    }, copiedDuring)
  })

  // Cleanup timeout on unmount
  useUnmount(() => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current)
    }
  })

  return {
    isSupported,
    text,
    copied,
    copy,
  }
}

API

export interface UseClipboardOptions {
  /**
   * Enabled reading for clipboard
   *
   * @default false
   */
  read?: boolean

  /**
   * Copy source
   */
  source?: string

  /**
   * Milliseconds to reset state of `copied` ref
   *
   * @default 1500
   */
  copiedDuring?: number

  /**
   * Whether fallback to document.execCommand('copy') if clipboard is undefined.
   *
   * @default false
   */
  legacy?: boolean
}

export interface UseClipboardReturn {
  isSupported: boolean
  text: string
  copied: boolean
  copy: (text?: string) => Promise<void>
}

type PermissionState = 'granted' | 'denied' | 'prompt' | undefined

/**
 * A hook to copy text to clipboard
 * @param options - The options for the clipboard
 * @returns The clipboard state and methods
 */
export function useClipboard(options?: UseClipboardOptions): UseClipboardReturn

Credits

Last updated on

On this page