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.jsonDependencies
- 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
| Prop | Type | Description |
|---|---|---|
| text* | string | The text users can select, cut, copy and paste. |
| className | string | Extra 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>
)
}