typescript 86 lines · 3 tabs

Copy-to-Clipboard Button With a useClipboard Hook and Timed Success Feedback

Shared by codesnips Sep 2026
3 tabs
export interface UseClipboardResult {
  copied: boolean;
  error: Error | null;
  copy: (value: string) => Promise<boolean>;
}

export interface CopyButtonProps {
  text: string;
  label?: string;
  copiedLabel?: string;
  timeout?: number;
}
3 files · typescript Explain with highlit

This snippet shows a small, reusable copy-to-clipboard feature split into a custom hook and the button that consumes it. The separation matters: the hook owns the async browser interaction and the transient UI state, while the component stays declarative and focused on rendering. This is the standard way to make clipboard logic reusable across a codebase without duplicating timers and error handling.

In useClipboard hook, the core is the async copy callback wrapping navigator.clipboard.writeText. The Clipboard API is promise-based and can reject — for example when the page lacks a secure context or the user denies permission — so the call is wrapped in a try/catch that records the outcome as copied or error. The timeout parameter drives a self-resetting success state: after a successful copy, a setTimeout flips copied back to false so the UI naturally returns to its idle label.

A subtle but important detail is timer cleanup. The pending timeout id is stored in a useRef so that rapid repeated clicks don't stack timers; each new copy clears the previous one via clearTimeout, and an unmount useEffect clears it too, preventing a state update on an unmounted component. The hook also guards against a missing navigator.clipboard so it degrades gracefully in older or insecure environments rather than throwing.

The returned tuple { copied, error, copy } gives the consumer exactly what it needs and nothing more. copy is wrapped in useCallback keyed on timeout so its identity is stable, which keeps it safe to pass into memoized children or dependency arrays.

In CopyButton component, the hook is invoked with the text to copy. The button's aria-live region and swapped label communicate the result to assistive technology and sighted users alike — the copied flag toggles the visible text and a check state, while error surfaces a fallback message. The button is disabled briefly through the copied state to signal the action landed.

The types tab defines the small contracts shared between the two files. Reaching for this pattern makes sense whenever copy affordances appear in more than one place — code blocks, share links, API keys — since the timing, cleanup, and error edge cases are solved once and reused.


Related snips

typescript
export interface RetryOptions {
  retries: number;
  baseMs: number;
  maxMs: number;
  signal?: AbortSignal;
  onRetry?: (attempt: number, delay: number, err: unknown) => void;

Exponential backoff with jitter for retries

typescript reliability retry
by codesnips 2 tabs
html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Semantic HTML Example</title>

Semantic HTML5 elements and accessibility best practices

html html5 semantics
by Alex Chang 2 tabs
javascript
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
  const success = true;

  setTimeout(() => {
    if (success) {

Promises and async/await patterns for asynchronous JavaScript

javascript promises async-await
by Alex Chang 1 tab
javascript
import { Controller } from "@hotwired/stimulus"
import Mousetrap from "mousetrap"

export default class extends Controller {
  connect() {
    // Global shortcuts

Keyboard shortcuts with Stimulus and Mousetrap

stimulus javascript ux
by Jordan Lee 2 tabs
typescript
export type Settled<R> =
  | { status: 'fulfilled'; value: R }
  | { status: 'rejected'; reason: unknown };

export interface ConcurrencyOptions {
  limit: number;

Simple concurrency limiter for batch operations

node concurrency async
by codesnips 2 tabs
javascript
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["form"]
  static values = { delay: { type: Number, default: 250 } }

Debounced live search with Stimulus + Turbo Streams

rails hotwire stimulus
by codesnips 4 tabs

Share this code

Here's the card — post it anywhere.

Copy-to-Clipboard Button With a useClipboard Hook and Timed Success Feedback — share card
Link copied