Skip to content
Skip to Content
DocsInteractive Arrows
Interactive Arrows

Interactive Arrows

Six canvas arrow and line patterns respond to the pointer with rotation, spacing, and opacity.

Move across the arrows. In the full preview, choose another behavior.

Component preview
Interactive Arrows

Install using CLI

npx shadcn@latest add "https://www.obsidianui.dev/r/interactive-arrows.json"

Usage

Code
"use client"; import { InteractiveArrows } from "@/components/block/interactive-arrows"; export default function Demo() { return <InteractiveArrows height={400} variant="arrows" showControls />; }

Install Manually

1

Install dependencies

npm install @radix-ui/react-select clsx lucide-react motion tailwind-merge
2

Copy the source code

Copy into components/block/interactive-arrows.jsx

"use client"; import { useState } from "react"; import { cn } from "@/lib/utils"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import Arrows from "@/lib/effects/interactive-arrows/arrows"; import ArrowsOpacity from "@/lib/effects/interactive-arrows/arrows-opacity"; import ArrowsLimit from "@/lib/effects/interactive-arrows/arrows-limit"; import ArrowsPlay from "@/lib/effects/interactive-arrows/arrows-play"; import Lines from "@/lib/effects/interactive-arrows/lines"; import Points from "@/lib/effects/interactive-arrows/points"; const engines = { arrows: Arrows, opacity: ArrowsOpacity, smooth: ArrowsLimit, playful: ArrowsPlay, lines: Lines, points: Points }; const behaviors = [ { value: "arrows", label: "Responsive arrows" }, { value: "opacity", label: "Opacity" }, { value: "smooth", label: "Smooth arrows" }, { value: "playful", label: "Playful arrows" }, { value: "lines", label: "Lines" }, { value: "points", label: "Points" }, ]; /** @param {{ variant?: "arrows" | "opacity" | "smooth" | "playful" | "lines" | "points", showControls?: boolean, className?: string, height?: import("react").CSSProperties["height"], style?: import("react").CSSProperties }} props */ export function InteractiveArrows({ variant = "arrows", showControls = false, className, height = 400, style } = {}) { const [selected, setSelected] = useState(variant); const active = showControls ? selected : variant; const Engine = engines[active] || Arrows; const dark = active === "smooth" || active === "playful"; return <div className={cn("relative isolate flex w-full flex-col bg-background", className)} style={{ height, containerType: "inline-size", ...style }}> <div data-arrow-stage="" className="relative min-h-0 flex-1 overflow-hidden rounded-xl" style={{ background: dark ? "#101010" : "#f5f4ef", color: dark ? "#fff" : "#111" }}> <Engine /> </div> {showControls && <div data-arrow-controls="" className="flex shrink-0 items-center justify-between gap-3 bg-background px-3 py-2.5 text-foreground"> <span className="text-xs text-muted-foreground">Arrow behavior</span> <Select value={selected} onValueChange={setSelected}> <SelectTrigger aria-label="Arrow behavior" className="h-9 min-w-[170px] rounded-lg border-border/70 bg-background text-xs shadow-none transition-[background-color,border-color] duration-150 hover:bg-muted/60 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/20 dark:bg-background dark:hover:bg-muted/60 [&_svg]:transition-transform [&_svg]:duration-200 data-[state=open]:[&_svg]:rotate-180 motion-reduce:transition-none motion-reduce:[&_svg]:transition-none"> <SelectValue /> </SelectTrigger> <SelectContent side="bottom" align="end" sideOffset={6} position="popper" className="rounded-xl border-border/70 p-1 shadow-[0_8px_28px_-12px_rgb(0_0_0/0.22)] data-[state=open]:duration-250 data-[state=closed]:duration-150 data-[state=open]:zoom-in-97 data-[state=closed]:zoom-out-99 motion-reduce:animate-none"> {behaviors.map(behavior => <SelectItem key={behavior.value} value={behavior.value} className="min-h-9 rounded-lg px-2.5 text-xs transition-colors duration-150 motion-reduce:transition-none">{behavior.label}</SelectItem>)} </SelectContent> </Select> </div>} </div>; }
3

Add the supporting file

Copy into components/ui/select.tsx

"use client" import * as React from "react" import * as SelectPrimitive from "@radix-ui/react-select" import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" import { cn } from "@/lib/utils" function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) { return <SelectPrimitive.Root data-slot="select" {...props} /> } function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) { return <SelectPrimitive.Group data-slot="select-group" {...props} /> } function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) { return <SelectPrimitive.Value data-slot="select-value" {...props} /> } function SelectTrigger({ className, size = "default", children, ...props }: React.ComponentProps<typeof SelectPrimitive.Trigger> & { size?: "sm" | "default" }) { return ( <SelectPrimitive.Trigger data-slot="select-trigger" data-size={size} className={cn( "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className )} {...props} > {children} <SelectPrimitive.Icon asChild> <ChevronDownIcon className="size-4 opacity-50" /> </SelectPrimitive.Icon> </SelectPrimitive.Trigger> ) } function SelectContent({ className, children, position = "popper", align = "center", ...props }: React.ComponentProps<typeof SelectPrimitive.Content>) { return ( <SelectPrimitive.Portal> <SelectPrimitive.Content data-slot="select-content" className={cn( "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md", position === "popper" && "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )} position={position} align={align} {...props} > <SelectScrollUpButton /> <SelectPrimitive.Viewport className={cn( "p-1", position === "popper" && "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1" )} > {children} </SelectPrimitive.Viewport> <SelectScrollDownButton /> </SelectPrimitive.Content> </SelectPrimitive.Portal> ) } function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) { return ( <SelectPrimitive.Label data-slot="select-label" className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)} {...props} /> ) } function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) { return ( <SelectPrimitive.Item data-slot="select-item" className={cn( "focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", className )} {...props} > <span className="absolute right-2 flex size-3.5 items-center justify-center"> <SelectPrimitive.ItemIndicator> <CheckIcon className="size-4" /> </SelectPrimitive.ItemIndicator> </span> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> </SelectPrimitive.Item> ) } function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) { return ( <SelectPrimitive.Separator data-slot="select-separator" className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)} {...props} /> ) } function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) { return ( <SelectPrimitive.ScrollUpButton data-slot="select-scroll-up-button" className={cn( "flex cursor-default items-center justify-center py-1", className )} {...props} > <ChevronUpIcon className="size-4" /> </SelectPrimitive.ScrollUpButton> ) } function SelectScrollDownButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) { return ( <SelectPrimitive.ScrollDownButton data-slot="select-scroll-down-button" className={cn( "flex cursor-default items-center justify-center py-1", className )} {...props} > <ChevronDownIcon className="size-4" /> </SelectPrimitive.ScrollDownButton> ) } export { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue, }
4

Add the supporting file

Copy into lib/effects/interactive-arrows/arrows-limit.jsx

"use client"; import { useEffect, useRef } from 'react'; import { useReducedMotion } from 'motion/react'; const ArrowsLimit = ({ rows = 3, columns = 6 }) => { const canvasRef = useRef(null); const motionEnabled = !useReducedMotion(); const mouseRef = useRef({ x: 0, y: 0 }); const arrowsRef = useRef([]); const animationFrameRef = useRef(null); useEffect(() => { class Point { constructor(x, y) { this.x = x || 0; this.y = y || 0; } } class Arrow { constructor(position) { this.pos = position; this.dx = 0; this.dy = 0; this.angle = 0; this.ease = 0.1; } update(mouseX, mouseY) { const targetDx = mouseX - this.pos.x; const targetDy = mouseY - this.pos.y; this.dx += (targetDx - this.dx) * this.ease * 0.35; this.dy += (targetDy - this.dy) * this.ease * 0.35; this.angle = Math.atan2(this.dy, this.dx); } draw(ctx) { ctx.save(); ctx.translate(this.pos.x, this.pos.y); ctx.rotate(this.angle); const arrowScale = Math.min(1, canvasRef.current.width / 800); ctx.scale(arrowScale, arrowScale); ctx.beginPath(); ctx.moveTo(50, 0); ctx.lineTo(-50, 0); ctx.moveTo(50, 0); ctx.lineTo(10, -40); ctx.moveTo(50, 0); ctx.lineTo(10, 40); ctx.lineWidth = 2; ctx.strokeStyle = 'white'; ctx.stroke(); ctx.restore(); } } const initializeArrows = (canvas) => { const arrows = []; const spacingX = canvas.width / (columns + 1); const spacingY = canvas.height / (rows + 1); for (let y = 1;y <= rows;y++) { for (let x = 1;x <= columns;x++) { arrows.push( new Arrow( new Point( x * spacingX, y * spacingY ) ) ); } } return arrows; }; const handleResize = () => { const canvas = canvasRef.current; if (canvas) { canvas.width = canvas.parentElement.clientWidth || 1; canvas.height = canvas.parentElement.clientHeight || 1; arrowsRef.current = initializeArrows(canvas); } }; const handleMouseMove = (e) => { if (!motionEnabled) return; const canvas = canvasRef.current; const rect = canvas.getBoundingClientRect(); if ( e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom ) { mouseRef.current = { x: e.clientX - rect.left, y: e.clientY - rect.top, }; } }; const main = () => { const canvas = canvasRef.current; const ctx = canvas?.getContext('2d'); if (!ctx) return; const arrows = arrowsRef.current; const mouse = mouseRef.current; ctx.clearRect(0, 0, canvas.width, canvas.height); arrows.forEach(arrow => { arrow.update(mouse.x, mouse.y); arrow.draw(ctx); }); if (motionEnabled) animationFrameRef.current = requestAnimationFrame(main); }; const canvas = canvasRef.current; if (!canvas || !canvas.getContext("2d")) return; canvas.width = canvas.parentElement.clientWidth || 1; canvas.height = canvas.parentElement.clientHeight || 1; arrowsRef.current = initializeArrows(canvas); const observer = new ResizeObserver(handleResize); observer.observe(canvas.parentElement); canvas.addEventListener('mousemove', handleMouseMove); main(); return () => { observer.disconnect(); canvas.removeEventListener('mousemove', handleMouseMove); if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); } }; }, [rows, columns, motionEnabled]); return ( <div className="w-full h-full"> <canvas ref={canvasRef} aria-hidden="true" className="w-full h-full " /> </div> ); }; export default ArrowsLimit;
5

Add the supporting file

Copy into lib/effects/interactive-arrows/arrows-opacity.jsx

"use client"; import { useEffect, useRef } from 'react'; import { useReducedMotion } from 'motion/react'; const ArrowsOpacity = () => { const canvasRef = useRef(null); const motionEnabled = !useReducedMotion(); const requestIdRef = useRef(null); const arrowArrRef = useRef([]); const mouseRef = useRef({ x: 0, y: 0 }); useEffect(() => { class Point { constructor(x, y) { this.x = x || 0; this.y = y || 0; } } class Arrow { constructor(position) { this.pos = position; this.dx = 0; this.dy = 0; this.angle = 0; this.dist = 0; } update(mx, my) { this.dx = mx - this.pos.x; this.dy = my - this.pos.y; this.dist = Math.sqrt(this.dx * this.dx + this.dy * this.dy); this.angle = Math.atan2(this.dy, this.dx); } draw(ctx) { ctx.save(); ctx.translate(this.pos.x, this.pos.y); ctx.rotate(this.angle); ctx.beginPath(); ctx.moveTo(30, 0); ctx.lineTo(-30, 0); ctx.moveTo(30, 0); ctx.lineTo(5, -30); ctx.moveTo(30, 0); ctx.lineTo(5, 30); ctx.lineWidth = 5; const alpha = 1 - (this.dist / 300); ctx.strokeStyle = `rgba(0, 0, 0, ${Math.max(0, alpha)})`; ctx.stroke(); ctx.restore(); } } const initializeArrows = (canvas) => { arrowArrRef.current = []; for (let y = 0;y < canvas.height / 20;y++) { for (let x = 0;x < canvas.width / 50;x++) { const arr = new Arrow(new Point(x * 75, y * 75)); arrowArrRef.current.push(arr); } } }; const handleResize = () => { const canvas = canvasRef.current; canvas.width = canvas.parentElement.clientWidth || 1; canvas.height = canvas.parentElement.clientHeight || 1; initializeArrows(canvas); }; const handleMouseMove = (e) => { if (!motionEnabled) return; const canvas = canvasRef.current; const rect = canvas.getBoundingClientRect(); const scaleX = canvas.width / rect.width; const scaleY = canvas.height / rect.height; const mouseX = (e.clientX - rect.left) * scaleX; const mouseY = (e.clientY - rect.top) * scaleY; if (mouseX >= 0 && mouseX <= canvas.width && mouseY >= 0 && mouseY <= canvas.height) { mouseRef.current = { x: mouseX, y: mouseY }; } }; const animate = () => { const canvas = canvasRef.current; const ctx = canvas?.getContext('2d'); if (!ctx) return; ctx.clearRect(0, 0, canvas.width, canvas.height); const { x: mx, y: my } = mouseRef.current; for (let y = 0;y < canvas.height / 20;y++) { for (let x = 0;x < canvas.width / 50;x++) { const arrow = arrowArrRef.current[y * Math.ceil(canvas.width / 50) + x]; if (arrow) { arrow.update(mx, my); arrow.draw(ctx); } } } if (motionEnabled) requestIdRef.current = requestAnimationFrame(animate); }; const canvas = canvasRef.current; if (!canvas || !canvas.getContext("2d")) return; canvas.width = canvas.parentElement.clientWidth || 1; canvas.height = canvas.parentElement.clientHeight || 1; initializeArrows(canvas); const observer = new ResizeObserver(handleResize); observer.observe(canvas.parentElement); canvas.addEventListener('mousemove', handleMouseMove); animate(); return () => { observer.disconnect(); canvas.removeEventListener('mousemove', handleMouseMove); if (requestIdRef.current) cancelAnimationFrame(requestIdRef.current); }; }, [motionEnabled]); return ( <canvas ref={canvasRef} aria-hidden="true" style={{ width: '100%', height: '100%', pointerEvents: 'auto' }} /> ); }; export default ArrowsOpacity;
6

Add the supporting file

Copy into lib/effects/interactive-arrows/arrows-play.jsx

"use client"; import { useEffect, useRef } from 'react'; import { useReducedMotion } from 'motion/react'; const ArrowsPlay = () => { const canvasRef = useRef(null); const motionEnabled = !useReducedMotion(); const mouseRef = useRef({ x: 0, y: 0 }); const arrowsRef = useRef([]); const animationFrameRef = useRef(null); const divRef = useRef(null); // For the center div with text useEffect(() => { class Point { constructor(x, y) { this.x = x || 0; this.y = y || 0; } } class Arrow { constructor(position) { this.pos = position; this.dx = 0; this.dy = 0; this.angle = 0; this.rotationEase = 0.12; } update(mouseX, mouseY) { this.dx = mouseX - this.pos.x; this.dy = mouseY - this.pos.y; const targetAngle = Math.atan2(this.dy, this.dx); // Ease rotation across the shortest arc so large direction changes do not snap. let delta = targetAngle - this.angle; while (delta > Math.PI) delta -= Math.PI * 2; while (delta < -Math.PI) delta += Math.PI * 2; this.angle += delta * this.rotationEase; } draw(ctx) { ctx.save(); ctx.translate(this.pos.x, this.pos.y); ctx.rotate(this.angle); ctx.beginPath(); ctx.moveTo(30, 0); ctx.lineTo(-30, 0); ctx.moveTo(30, 0); ctx.lineTo(10, -20); ctx.moveTo(30, 0); ctx.lineTo(10, 20); ctx.lineWidth = 2.5; ctx.strokeStyle = 'white'; ctx.stroke(); ctx.restore(); } } const initializeArrows = (canvas) => { const arrows = []; const spacing = 120; const cols = Math.floor(canvas.width / spacing); const rows = Math.floor(canvas.height / spacing); const xPadding = (canvas.width - (cols * spacing)) / 2; const yPadding = (canvas.height - (rows * spacing)) / 2; // Get div position relative to canvas const divRect = divRef.current.getBoundingClientRect(); const canvasRect = canvas.getBoundingClientRect(); const divLeft = divRect.left - canvasRect.left; const divRight = divRect.right - canvasRect.left; const divTop = divRect.top - canvasRect.top; const divBottom = divRect.bottom - canvasRect.top; for (let y = 0;y <= rows;y++) { for (let x = 0;x <= cols;x++) { const arrowPos = new Point( x * spacing + xPadding, y * spacing + yPadding ); if ( arrowPos.x >= divLeft && arrowPos.x <= divRight && arrowPos.y >= divTop && arrowPos.y <= divBottom ) { continue; } arrows.push(new Arrow(arrowPos)); } } return arrows; }; const handleResize = () => { const canvas = canvasRef.current; if (canvas) { canvas.width = canvas.parentElement.clientWidth || 1; canvas.height = canvas.parentElement.clientHeight || 1; arrowsRef.current = initializeArrows(canvas); } }; const handleMouseMove = (e) => { if (!motionEnabled) return; const canvas = canvasRef.current; const rect = canvas.getBoundingClientRect(); if ( e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom ) { mouseRef.current = { x: e.clientX - rect.left, y: e.clientY - rect.top, }; } }; const main = () => { const canvas = canvasRef.current; const ctx = canvas?.getContext('2d'); if (!ctx) return; const arrows = arrowsRef.current; const mouse = mouseRef.current; ctx.clearRect(0, 0, canvas.width, canvas.height); arrows.forEach(arrow => { arrow.update(mouse.x, mouse.y); arrow.draw(ctx); }); if (motionEnabled) animationFrameRef.current = requestAnimationFrame(main); }; const canvas = canvasRef.current; if (!canvas || !canvas.getContext("2d")) return; canvas.width = canvas.parentElement.clientWidth || 1; canvas.height = canvas.parentElement.clientHeight || 1; mouseRef.current = { x: canvas.width / 2, y: canvas.height / 2, }; arrowsRef.current = initializeArrows(canvas); const observer = new ResizeObserver(handleResize); observer.observe(canvas.parentElement); canvas.addEventListener('mousemove', handleMouseMove); main(); return () => { observer.disconnect(); canvas.removeEventListener('mousemove', handleMouseMove); if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); } }; }, [motionEnabled]); return ( <div className="relative w-full h-full "> <canvas ref={canvasRef} aria-hidden="true" className="w-full h-full" style={{ cursor: 'pointer' }} /> <div ref={divRef} className="pointer-events-none absolute left-1/2 top-1/2 z-10 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center" style={{ cursor: "pointer", height: "25cqw", width: "32cqw", backgroundColor: "transparent", }} > <p className='text-center text-[15cqw] leading-none text-white transition-all duration-500 ease'> Play </p> </div> </div> ); }; export default ArrowsPlay;
7

Add the supporting file

Copy into lib/effects/interactive-arrows/arrows.jsx

"use client"; import { useEffect, useRef } from 'react'; import { useReducedMotion } from 'motion/react'; const Arrows = () => { const canvasRef = useRef(null); const motionEnabled = !useReducedMotion(); const mouseRef = useRef({ x: 0, y: 0 }); const arrowsRef = useRef([]); const animationFrameRef = useRef(null); useEffect(() => { class Point { constructor(x, y) { this.x = x || 0; this.y = y || 0; } } class Arrow { constructor(position) { this.pos = position; this.dx = 0; this.dy = 0; this.angle = 0; this.isHovered = false; this.originalPos = { ...position }; this.targetPos = { ...position }; } update(mouseX, mouseY) { this.dx = mouseX - this.pos.x; this.dy = mouseY - this.pos.y; const targetAngle = Math.atan2(this.dy, this.dx) * 0.95; // Lerp angle via shortest arc to avoid wrap-around jumps let delta = targetAngle - this.angle; while (delta > Math.PI) delta -= 2 * Math.PI; while (delta < -Math.PI) delta += 2 * Math.PI; this.angle += delta * 0.15; // Check if mouse is near this arrow const distance = Math.sqrt( Math.pow(mouseX - this.pos.x, 2) + Math.pow(mouseY - this.pos.y, 2) ); const wasHovered = this.isHovered; this.isHovered = distance < 30; // Handle spacing animation if (this.isHovered !== wasHovered) { if (this.isHovered) { // Push surrounding arrows away arrowsRef.current.forEach(otherArrow => { if (otherArrow !== this) { const dx = otherArrow.pos.x - this.pos.x; const dy = otherArrow.pos.y - this.pos.y; const dist = Math.sqrt(dx * dx + dy * dy); if (dist < 70) { const pushForce = (70 - dist) / 70; otherArrow.targetPos = { x: otherArrow.originalPos.x + (dx / dist) * 20 * pushForce, y: otherArrow.originalPos.y + (dy / dist) * 20 * pushForce }; } } }); } else { // Reset surrounding arrows arrowsRef.current.forEach(arrow => { arrow.targetPos = { ...arrow.originalPos }; }); } } // Smooth position transition this.pos.x += (this.targetPos.x - this.pos.x) * 0.1; this.pos.y += (this.targetPos.y - this.pos.y) * 0.1; } draw(ctx) { ctx.save(); ctx.translate(this.pos.x, this.pos.y); ctx.rotate(this.angle); ctx.beginPath(); ctx.moveTo(20, 0); ctx.lineTo(-20, 0); ctx.moveTo(20, 0); ctx.lineTo(5, -15); ctx.moveTo(20, 0); ctx.lineTo(5, 15); ctx.lineWidth = this.isHovered ? 3 : 2; ctx.strokeStyle = 'black'; ctx.stroke(); ctx.restore(); } } const initializeArrows = (canvas) => { const arrows = []; const spacing = 50; const cols = Math.floor(canvas.width / spacing); const rows = Math.floor(canvas.height / spacing); const xPadding = (canvas.width - (cols * spacing)) / 2; const yPadding = (canvas.height - (rows * spacing)) / 2; for (let y = 0;y <= rows;y++) { for (let x = 0;x <= cols;x++) { arrows.push(new Arrow(new Point(x * spacing + xPadding, y * spacing + yPadding))); } } return arrows; }; const handleResize = () => { const canvas = canvasRef.current; if (canvas) { canvas.width = canvas.parentElement.clientWidth || 1; canvas.height = canvas.parentElement.clientHeight || 1; arrowsRef.current = initializeArrows(canvas); } }; const handleMouseMove = (e) => { if (!motionEnabled) return; const canvas = canvasRef.current; if (canvas) { const rect = canvas.getBoundingClientRect(); const scaleX = canvas.width / rect.width; const scaleY = canvas.height / rect.height; mouseRef.current = { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY, }; } }; const main = () => { const canvas = canvasRef.current; const ctx = canvas?.getContext('2d'); if (!ctx) return; const arrows = arrowsRef.current; const mouse = mouseRef.current; ctx.clearRect(0, 0, canvas.width, canvas.height); arrows.forEach(arrow => { arrow.update(mouse.x, mouse.y); arrow.draw(ctx); }); if (motionEnabled) animationFrameRef.current = requestAnimationFrame(main); }; const canvas = canvasRef.current; if (!canvas || !canvas.getContext("2d")) return; canvas.width = canvas.parentElement.clientWidth || 1; canvas.height = canvas.parentElement.clientHeight || 1; arrowsRef.current = initializeArrows(canvas); const observer = new ResizeObserver(handleResize); observer.observe(canvas.parentElement); canvas.addEventListener('mousemove', handleMouseMove); main(); return () => { observer.disconnect(); canvas.removeEventListener('mousemove', handleMouseMove); if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); } }; }, [motionEnabled]); return ( // <div className="w-full h-full bg-white"> <canvas ref={canvasRef} aria-hidden="true" className="w-full h-full" /> // </div> ); }; export default Arrows;
8

Add the supporting file

Copy into lib/effects/interactive-arrows/lines.jsx

"use client"; import { useEffect, useRef } from 'react'; import { useReducedMotion } from 'motion/react'; const Lines = () => { const canvasRef = useRef(null); const motionEnabled = !useReducedMotion(); const mouseRef = useRef({ x: 0, y: 0 }); const pointsRef = useRef([]); const animationFrameRef = useRef(null); const lineLength = 30; useEffect(() => { class Point { constructor(x, y) { this.x = x || 0; this.y = y || 0; } draw(ctx, mouseX, mouseY) { const dx = mouseX - this.x; const dy = mouseY - this.y; const distance = Math.sqrt(dx * dx + dy * dy) || 1; const unitX = dx / distance; const unitY = dy / distance; const lineEndX = this.x + unitX * lineLength; const lineEndY = this.y + unitY * lineLength; ctx.beginPath(); ctx.moveTo(this.x, this.y); ctx.lineTo(lineEndX, lineEndY); ctx.strokeStyle = 'black'; ctx.lineWidth = 1.5; ctx.stroke(); } } const initializePoints = (canvas) => { const points = []; const spacing = 60; const cols = Math.floor(canvas.width / spacing); const rows = Math.floor(canvas.height / spacing); for (let y = 0;y <= rows;y++) { for (let x = 0;x <= cols;x++) { points.push(new Point(x * spacing, y * spacing)); } } return points; }; const handleResize = () => { const canvas = canvasRef.current; if (canvas) { canvas.width = canvas.parentElement.clientWidth || 1; canvas.height = canvas.parentElement.clientHeight || 1; pointsRef.current = initializePoints(canvas); } }; const handleMouseMove = (e) => { if (!motionEnabled) return; const canvas = canvasRef.current; const rect = canvas.getBoundingClientRect(); const scaleX = canvas.width / rect.width; const scaleY = canvas.height / rect.height; mouseRef.current = { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY, }; }; const main = () => { const canvas = canvasRef.current; const ctx = canvas?.getContext('2d'); if (!ctx) return; ctx.clearRect(0, 0, canvas.width, canvas.height); pointsRef.current.forEach(point => point.draw(ctx, mouseRef.current.x, mouseRef.current.y)); if (motionEnabled) animationFrameRef.current = requestAnimationFrame(main); }; const canvas = canvasRef.current; if (!canvas || !canvas.getContext("2d")) return; canvas.width = canvas.parentElement.clientWidth || 1; canvas.height = canvas.parentElement.clientHeight || 1; pointsRef.current = initializePoints(canvas); const observer = new ResizeObserver(handleResize); observer.observe(canvas.parentElement); canvas.addEventListener('mousemove', handleMouseMove); main(); return () => { observer.disconnect(); canvas.removeEventListener('mousemove', handleMouseMove); cancelAnimationFrame(animationFrameRef.current); }; }, [motionEnabled]); return <canvas ref={canvasRef} aria-hidden="true" className="w-full h-full " style={{ cursor: 'pointer' }} />; }; export default Lines;
9

Add the supporting file

Copy into lib/effects/interactive-arrows/points.jsx

"use client"; import { useEffect, useRef } from 'react'; import { useReducedMotion } from 'motion/react'; const Points = () => { const canvasRef = useRef(null); const motionEnabled = !useReducedMotion(); const mouseRef = useRef({ x: 0, y: 0 }); const pointsRef = useRef([]); const animationFrameRef = useRef(null); const mouseEnteredRef = useRef(false); const lineLengthRef = useRef(0); const maxLineLength = 25; const easingSpeed = 0.05; useEffect(() => { class Point { constructor(x, y) { this.x = x || 0; this.y = y || 0; } draw(ctx, mouseX, mouseY) { if (mouseEnteredRef.current) { const dx = mouseX - this.x; const dy = mouseY - this.y; const distance = Math.sqrt(dx * dx + dy * dy) || 1; const unitX = dx / distance; const unitY = dy / distance; const lineEndX = this.x + unitX * lineLengthRef.current; const lineEndY = this.y + unitY * lineLengthRef.current; ctx.beginPath(); ctx.moveTo(this.x, this.y); ctx.lineTo(lineEndX, lineEndY); ctx.strokeStyle = 'black'; ctx.lineWidth = 1.5; ctx.stroke(); } else { ctx.beginPath(); ctx.arc(this.x, this.y, 2, 0, Math.PI * 2); ctx.fillStyle = 'black'; ctx.fill(); } } } const initializePoints = (canvas) => { const points = []; const spacing = 60; const cols = Math.floor(canvas.width / spacing); const rows = Math.floor(canvas.height / spacing); const xPadding = (canvas.width - (cols * spacing)) / 2; const yPadding = (canvas.height - (rows * spacing)) / 2; for (let y = 0;y <= rows;y++) { for (let x = 0;x <= cols;x++) { points.push(new Point( x * spacing + xPadding, y * spacing + yPadding )); } } return points; }; const handleResize = () => { const canvas = canvasRef.current; if (canvas) { canvas.width = canvas.parentElement.clientWidth || 1; canvas.height = canvas.parentElement.clientHeight || 1; pointsRef.current = initializePoints(canvas); } }; const handleMouseMove = (e) => { if (!motionEnabled) return; const canvas = canvasRef.current; const rect = canvas.getBoundingClientRect(); const scaleX = canvas.width / rect.width; const scaleY = canvas.height / rect.height; mouseRef.current = { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY, }; }; const handleMouseEnter = () => { mouseEnteredRef.current = true; } const handleMouseLeave = () => { mouseEnteredRef.current = false; }; const updateLineLength = () => { if (mouseEnteredRef.current && lineLengthRef.current < maxLineLength) { lineLengthRef.current = Math.min(lineLengthRef.current + easingSpeed, maxLineLength); } else if (!mouseEnteredRef.current && lineLengthRef.current > 0) { lineLengthRef.current = Math.max(lineLengthRef.current - easingSpeed, 0); } }; const main = () => { const canvas = canvasRef.current; const ctx = canvas?.getContext('2d'); if (!ctx) return; const points = pointsRef.current; const mouse = mouseRef.current; ctx.clearRect(0, 0, canvas.width, canvas.height); updateLineLength(); points.forEach(point => { point.draw(ctx, mouse.x, mouse.y); }); if (motionEnabled) animationFrameRef.current = requestAnimationFrame(main); }; const canvas = canvasRef.current; if (!canvas || !canvas.getContext("2d")) return; canvas.width = canvas.parentElement.clientWidth || 1; canvas.height = canvas.parentElement.clientHeight || 1; pointsRef.current = initializePoints(canvas); const observer = new ResizeObserver(handleResize); observer.observe(canvas.parentElement); canvas.addEventListener('mousemove', handleMouseMove); canvas.addEventListener('mouseenter', handleMouseEnter); canvas.addEventListener('mouseleave', handleMouseLeave); main(); return () => { observer.disconnect(); canvas.removeEventListener('mousemove', handleMouseMove); canvas.removeEventListener('mouseenter', handleMouseEnter); canvas.removeEventListener('mouseleave', handleMouseLeave); if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); } }; }, [motionEnabled]); return ( <div className="w-full h-full "> <canvas ref={canvasRef} aria-hidden="true" className="w-full h-full" style={{ cursor: 'pointer' }} /> </div> ); }; export default Points;
10

Add the supporting file

Copy into lib/utils.ts

import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }

Preview behavior

Move across the arrows. In the full preview, choose another behavior. The effect stays inside its container and respects reduced motion preferences.

Last updated on