makeui
DocsComponents

© 2026 makeui. Open source, MIT licensed.

DocsComponentsGitHub

Cut & Paste

Text you can cut, copy and paste, with a pair of scissors that snips it out.

Preview

Installation

pnpm dlx shadcn@latest add https://makeui.dev/r/cut-paste.json

Dependencies

  • motion
  • lucide-react

Interaction

Select some text and a menu appears above it. Cut lifts the selection out of the line, leaves a dashed gap and snips it with scissors. Click to place the caret and choose Paste to drop it back in. Copy flashes the selection pink.

Props

PropTypeDescription
text*stringThe text users can select, cut, copy and paste.
classNamestringExtra classes merged onto the root element.

How to use

import CutPaste from "@/components/ui/cut-paste"

export function Example() {
  return <CutPaste text="Great products speak one language." />
}

Source code

components/ui/cut-paste.tsx
"use client"

import { useEffect, useRef, useState } from "react"
import { AnimatePresence, motion } from "motion/react"
import { Clipboard, Copy, Scissors } from "lucide-react"
import { cn } from "@/lib/utils"

type Menu = { x: number; y: number; mode: "select" | "paste"; start: number; end: number }
type Gap = { at: number; width: number; id: number }
type Flash = { start: number; end: number; kind: "copy" | "paste"; id: number }
type Ghost = { text: string; x: number; y: number; id: number }

const FLASH = {
  copy: ["rgba(236,72,153,0.38)", "rgba(236,72,153,0)"],
  paste: ["rgba(16,185,129,0.3)", "rgba(16,185,129,0)"],
} as const

export default function CutPaste({
  text: initial,
  className,
}: {
  text: string
  className?: string
}) {
  const [text, setText] = useState(initial)
  const [clip, setClip] = useState<string | null>(null)
  const [gap, setGap] = useState<Gap | null>(null)
  const [menu, setMenu] = useState<Menu | null>(null)
  const [flash, setFlash] = useState<Flash | null>(null)
  const [ghost, setGhost] = useState<Ghost | null>(null)
  const root = useRef<HTMLDivElement>(null)
  const para = useRef<HTMLParagraphElement>(null)

  useEffect(() => {
    setText(initial)
    setClip(null)
    setGap(null)
    setFlash(null)
    setMenu(null)
  }, [initial])

  // Offset of a DOM position inside the paragraph's text. The gap and flash wrappers
  // contribute no characters, so this stays correct after cutting and pasting.
  const offsetOf = (node: Node, offset: number) => {
    const r = document.createRange()
    r.selectNodeContents(para.current!)
    r.setEnd(node, offset)
    return r.toString().length
  }

  const readSelection = (clientX: number, clientY: number) => {
    const sel = window.getSelection()
    const p = para.current
    const box = root.current?.getBoundingClientRect()
    if (!sel || !sel.rangeCount || !p || !box) return setMenu(null)
    const range = sel.getRangeAt(0)
    if (!p.contains(range.commonAncestorContainer)) return setMenu(null)
    const start = offsetOf(range.startContainer, range.startOffset)
    const end = offsetOf(range.endContainer, range.endOffset)
    const clampX = (x: number) => Math.min(Math.max(x, 90), box.width - 90)
    if (start !== end) {
      const r = range.getBoundingClientRect()
      setMenu({
        x: clampX(r.left + r.width / 2 - box.left),
        y: r.top - box.top,
        mode: "select",
        start,
        end,
      })
    } else if (clip) {
      setMenu({
        x: clampX(clientX - box.left),
        y: clientY - box.top - 8,
        mode: "paste",
        start,
        end,
      })
    } else {
      setMenu(null)
    }
  }

  const cut = () => {
    if (!menu) return
    const sel = window.getSelection()
    const range = sel && sel.rangeCount ? sel.getRangeAt(0) : null
    const box = root.current?.getBoundingClientRect()
    const { start, end } = menu
    const picked = text.slice(start, end)
    if (range && box) {
      const r = range.getBoundingClientRect()
      setGhost({
        text: picked,
        x: r.left + r.width / 2 - box.left,
        y: r.top - box.top,
        id: Date.now(),
      })
      setGap({ at: start, width: Math.min(Math.max(r.width, 28), 180), id: Date.now() })
    }
    setClip(picked)
    setText(text.slice(0, start) + text.slice(end))
    setFlash(null)
    setMenu(null)
    window.getSelection()?.removeAllRanges()
  }

  const copy = () => {
    if (!menu) return
    const { start, end } = menu
    const picked = text.slice(start, end)
    navigator.clipboard?.writeText(picked).catch(() => {})
    setClip(picked)
    setFlash({ start, end, kind: "copy", id: Date.now() })
    setMenu(null)
    window.getSelection()?.removeAllRanges()
  }

  const paste = () => {
    if (!menu || !clip) return
    const at = menu.start
    setText(text.slice(0, at) + clip + text.slice(at))
    setFlash({ start: at, end: at + clip.length, kind: "paste", id: Date.now() })
    setGap(null)
    setMenu(null)
    window.getSelection()?.removeAllRanges()
  }

  // Split the text at every point where something is drawn, then render piece by piece.
  const bounds = Array.from(
    new Set([
      0,
      text.length,
      ...(gap ? [gap.at] : []),
      ...(flash ? [flash.start, flash.end] : []),
    ]),
  ).sort((a, b) => a - b)

  const content = bounds.flatMap((b, k) => {
    const out: React.ReactNode[] = []
    if (gap && gap.at === b) {
      out.push(
        <motion.span
          key={`gap-${gap.id}`}
          contentEditable={false}
          aria-hidden
          initial={{ width: gap.width }}
          animate={{ width: Math.min(gap.width, 28) }}
          transition={{ delay: 0.5, duration: 0.5, ease: "easeInOut" }}
          className="relative mx-0.5 inline-block h-[1.1em] translate-y-[0.2em] rounded-sm border-b-2 border-dashed border-muted-foreground/60"
        >
          <motion.span
            key={`snip-${gap.id}`}
            className="absolute left-1/2 top-1/2 text-accent"
            style={{ x: "-50%", y: "-50%" }}
            initial={{ opacity: 0, scale: 0.4, rotate: -40 }}
            animate={{
              opacity: [0, 1, 1, 1, 0],
              scale: [0.4, 1.1, 1, 1.1, 1],
              rotate: [-40, 0, -14, 0, 0],
            }}
            transition={{ duration: 1.4, times: [0, 0.2, 0.45, 0.7, 1] }}
          >
            <Scissors className="size-4" />
          </motion.span>
        </motion.span>,
      )
    }
    if (k < bounds.length - 1) {
      const a = b
      const z = bounds[k + 1]
      const piece = text.slice(a, z)
      if (flash && a >= flash.start && z <= flash.end) {
        const [from, to] = FLASH[flash.kind]
        out.push(
          <motion.mark
            key={`flash-${flash.id}`}
            initial={{ backgroundColor: from }}
            animate={{ backgroundColor: to }}
            transition={{ duration: 1.3, ease: "easeOut" }}
            className="rounded-sm text-inherit"
          >
            {piece}
          </motion.mark>,
        )
      } else out.push(<span key={`t-${a}`}>{piece}</span>)
    }
    return out
  })

  return (
    <div ref={root} className={cn("relative", className)}>
      <p
        ref={para}
        onPointerDown={() => setMenu(null)}
        onPointerUp={(e) => {
          const { clientX, clientY } = e
          setTimeout(() => readSelection(clientX, clientY), 0)
        }}
        className="select-text text-pretty leading-relaxed"
      >
        {content}
      </p>
      <p className="mt-3 text-xs text-muted-foreground" aria-live="polite">
        {clip
          ? "Click where you want it, then choose Paste."
          : "Select some text to cut or copy it."}
      </p>

      <AnimatePresence>
        {ghost && (
          <motion.span
            key={ghost.id}
            aria-hidden
            className="pointer-events-none absolute z-10 max-w-64 -translate-x-1/2 truncate rounded-md bg-accent/15 px-1.5 py-0.5 text-sm text-accent"
            style={{ left: ghost.x, top: ghost.y }}
            initial={{ y: 0, opacity: 1, scale: 1 }}
            animate={{ y: -26, opacity: 0, scale: 0.92 }}
            transition={{ duration: 0.6, ease: "easeOut" }}
            onAnimationComplete={() => setGhost(null)}
          >
            {ghost.text}
          </motion.span>
        )}

        {menu && (
          <motion.div
            key="menu"
            role="toolbar"
            aria-label="Text actions"
            initial={{ opacity: 0, y: 6, scale: 0.96 }}
            animate={{ opacity: 1, y: 0, scale: 1 }}
            exit={{ opacity: 0, y: 4, scale: 0.97 }}
            transition={{ duration: 0.14 }}
            className="absolute z-20 flex -translate-x-1/2 -translate-y-full gap-0.5 rounded-xl border border-border bg-card p-1 shadow-lg"
            style={{ left: menu.x, top: menu.y - 8 }}
            onPointerDown={(e) => e.preventDefault()} // keep the selection alive
          >
            {menu.mode === "select" ? (
              <>
                <MenuButton
                  icon={<Scissors className="size-3.5" />}
                  label="Cut"
                  onClick={cut}
                />
                <MenuButton
                  icon={<Copy className="size-3.5" />}
                  label="Copy"
                  onClick={copy}
                />
              </>
            ) : (
              <MenuButton
                icon={<Clipboard className="size-3.5" />}
                label="Paste"
                onClick={paste}
              />
            )}
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  )
}

function MenuButton({
  icon,
  label,
  onClick,
}: {
  icon: React.ReactNode
  label: string
  onClick: () => void
}) {
  return (
    <button
      type="button"
      onClick={onClick}
      className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors hover:bg-muted"
    >
      {icon}
      {label}
    </button>
  )
}

On this page

Great products speak one language. A design system is a collection of reusable components that can be assembled together to build any number of applications, guided by clear standards. What's the difference between a design system and a style guide? One documents the decisions, the other ships them as working parts.

Select some text to cut or copy it.