Skip to content

Commit

Permalink
Feature (Components): New Pointer component (#544)
Browse files Browse the repository at this point in the history
* feat: add Pointer component to registry and documentation

* fix: format and lint pointer registry and demo files

* fix: update pointer demo

* fix: bump

* fix: update registry

* fix: update date

---------

Co-authored-by: Dillion Verma <[email protected]>
  • Loading branch information
h3rmel and dillionverma authored Feb 17, 2025
1 parent e5ae0b2 commit 27ff381
Show file tree
Hide file tree
Showing 10 changed files with 347 additions and 0 deletions.
47 changes: 47 additions & 0 deletions __registry__/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,30 @@ export const Index: Record<string, any> = {
}),
meta: undefined,
},
pointer: {
name: "pointer",
description:
"A component that displays a pointer when hovering over an element",
type: "registry:ui",
registryDependencies: undefined,
files: [
{
path: "registry/magicui/pointer.tsx",
type: "registry:ui",
target: "components/magicui/pointer.tsx",
},
],
component: React.lazy(async () => {
const mod = await import("@/registry/magicui/pointer.tsx");
const exportName =
Object.keys(mod).find(
(key) =>
typeof mod[key] === "function" || typeof mod[key] === "object",
) || item.name;
return { default: mod.default || mod[exportName] };
}),
meta: undefined,
},
"neon-gradient-card": {
name: "neon-gradient-card",
description: "A beautiful neon card effect",
Expand Down Expand Up @@ -1784,6 +1808,29 @@ export const Index: Record<string, any> = {
}),
meta: undefined,
},
"pointer-demo-1": {
name: "pointer-demo-1",
description: "Example showing a pointer effect component",
type: "registry:example",
registryDependencies: ["https://magicui.design/r/pointer"],
files: [
{
path: "registry/example/pointer-demo-1.tsx",
type: "registry:example",
target: "components/pointer-demo-1.tsx",
},
],
component: React.lazy(async () => {
const mod = await import("@/registry/example/pointer-demo-1.tsx");
const exportName =
Object.keys(mod).find(
(key) =>
typeof mod[key] === "function" || typeof mod[key] === "object",
) || item.name;
return { default: mod.default || mod[exportName] };
}),
meta: undefined,
},
"neon-gradient-card-demo": {
name: "neon-gradient-card-demo",
description: "Example showing a beautiful neon card effect.",
Expand Down
6 changes: 6 additions & 0 deletions config/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,12 @@ export const docsConfig: DocsConfig = {
items: [],
label: "New",
},
{
title: "Pointer",
href: `/docs/components/pointer`,
items: [],
label: "New",
},
],
},
{
Expand Down
46 changes: 46 additions & 0 deletions content/docs/components/pointer.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
title: Pointer
date: 2025-02-17
description: A component that displays a pointer when hovering over an element
author: h3rmel
published: true
---

<ComponentPreview name="pointer-demo-1" />

## Installation

<Tabs defaultValue="cli">

<TabsList>
<TabsTrigger value="cli">CLI</TabsTrigger>
<TabsTrigger value="manual">Manual</TabsTrigger>
</TabsList>
<TabsContent value="cli">

```bash
npx shadcn@latest add "https://magicui.design/r/pointer"
```

</TabsContent>

<TabsContent value="manual">

<Step>Copy and paste the following code into your project.</Step>

<ComponentSource name="pointer" />

<Step>Update the import paths to match your project setup.</Step>

</TabsContent>

</Tabs>

## Examples

## Props

| Property | Type | Default | Description |
| ----------- | ----------------- | ------- | ----------------------------------------------- |
| `children` | `React.ReactNode` | - | The content that will be wrapped by the pointer |
| `className` | `string` | - | The className of the pointer |
18 changes: 18 additions & 0 deletions public/r/pointer-demo-1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "pointer-demo-1",
"type": "registry:example",
"title": "Pointer Demo 1",
"description": "Example showing a pointer effect component",
"registryDependencies": [
"https://magicui.design/r/pointer"
],
"files": [
{
"path": "registry/example/pointer-demo-1.tsx",
"content": "\"use client\";\n\nimport { PointerWrapper } from \"@/components/magicui/pointer\";\n\nexport default function PointerDemo1() {\n return (\n <PointerWrapper>\n <div className=\"relative flex size-[500px] items-center justify-center overflow-hidden rounded-lg border bg-background\">\n <span className=\"pointer-events-none whitespace-pre-wrap bg-gradient-to-b from-black to-gray-300/80 bg-clip-text text-center text-8xl font-semibold leading-none text-transparent dark:from-white dark:to-slate-900/10\">\n Pointer\n </span>\n </div>\n </PointerWrapper>\n );\n}\n",
"type": "registry:example",
"target": "components/pointer-demo-1.tsx"
}
]
}
18 changes: 18 additions & 0 deletions public/r/pointer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "pointer",
"type": "registry:ui",
"title": "Pointer",
"description": "A component that displays a pointer when hovering over an element",
"dependencies": [
"motion"
],
"files": [
{
"path": "registry/magicui/pointer.tsx",
"content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { AnimatePresence, motion, useMotionValue } from \"motion/react\";\nimport React, { useEffect, useRef, useState } from \"react\";\n\n/**\n * @property {React.ReactNode} children - The child elements to be wrapped\n */\ninterface PointerWrapperProps extends React.HTMLAttributes<HTMLDivElement> {\n children?: React.ReactNode;\n}\n\n/**\n * A component that wraps content and adds a custom pointer animation when hovering\n * over the wrapped area. The pointer follows the mouse movement within the wrapped area.\n *\n * @component\n * @param {PointerWrapperProps} props - The component props\n */\nexport function PointerWrapper({\n children,\n className,\n ...props\n}: PointerWrapperProps) {\n const x = useMotionValue(0);\n const y = useMotionValue(0);\n\n const ref = useRef<HTMLDivElement>(null);\n const [rect, setRect] = useState<DOMRect | null>(null);\n const [isInside, setIsInside] = useState<boolean>(false);\n\n useEffect(() => {\n function updateRect() {\n if (ref.current) {\n setRect(ref.current.getBoundingClientRect());\n }\n }\n\n // Initial rect calculation\n updateRect();\n\n // Update rect on window resize\n window.addEventListener(\"resize\", updateRect);\n\n return () => {\n window.removeEventListener(\"resize\", updateRect);\n };\n }, []);\n\n function handleMouseMove(e: React.MouseEvent<HTMLDivElement>) {\n if (rect) {\n x.set(e.clientX - rect.left);\n y.set(e.clientY - rect.top);\n }\n }\n\n function handleMouseLeave() {\n setIsInside(false);\n }\n\n function handleMouseEnter() {\n if (ref.current) {\n setRect(ref.current.getBoundingClientRect());\n setIsInside(true);\n }\n }\n\n return (\n <div\n ref={ref}\n className={cn(\"relative cursor-none\", className)}\n onMouseLeave={handleMouseLeave}\n onMouseEnter={handleMouseEnter}\n onMouseMove={handleMouseMove}\n {...props}\n >\n <AnimatePresence>{isInside && <Pointer x={x} y={y} />}</AnimatePresence>\n {children}\n </div>\n );\n}\n\n/**\n * @property {MotionValue<number>} x - The x-coordinate position of the pointer\n * @property {MotionValue<number>} y - The y-coordinate position of the pointer\n */\ninterface PointerProps {\n x: any;\n y: any;\n}\n\n/**\n * A custom pointer component that displays an animated arrow cursor\n *\n * @description Used internally by PointerWrapper to show the custom cursor\n *\n * @component\n * @param {PointerProps} props - The component props\n */\nfunction Pointer({ x, y }: PointerProps): JSX.Element {\n return (\n <motion.div\n className=\"pointer-events-none absolute z-50 h-4 w-4 rounded-full\"\n style={{\n top: y,\n left: x,\n }}\n initial={{\n scale: 1,\n opacity: 1,\n }}\n animate={{\n scale: 1,\n opacity: 1,\n }}\n exit={{\n scale: 0,\n opacity: 0,\n }}\n >\n <svg\n stroke=\"currentColor\"\n fill=\"currentColor\"\n strokeWidth=\"1\"\n viewBox=\"0 0 16 16\"\n className=\"h-6 w-6 translate-x-[-12px] translate-y-[-10px] rotate-[-70deg] stroke-white text-black\"\n height=\"1em\"\n width=\"1em\"\n xmlns=\"http://www.w3.org/2000/svg\"\n >\n <path d=\"M14.082 2.182a.5.5 0 0 1 .103.557L8.528 15.467a.5.5 0 0 1-.917-.007L5.57 10.694.803 8.652a.5.5 0 0 1-.006-.916l12.728-5.657a.5.5 0 0 1 .556.103z\"></path>\n </svg>\n </motion.div>\n );\n}\n",
"type": "registry:ui",
"target": "components/magicui/pointer.tsx"
}
]
}
32 changes: 32 additions & 0 deletions registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,22 @@
}
]
},
{
"name": "pointer",
"type": "registry:ui",
"title": "Pointer",
"description": "A component that displays a pointer when hovering over an element",
"dependencies": [
"motion"
],
"files": [
{
"path": "registry/magicui/pointer.tsx",
"type": "registry:ui",
"target": "components/magicui/pointer.tsx"
}
]
},
{
"name": "neon-gradient-card",
"type": "registry:ui",
Expand Down Expand Up @@ -1577,6 +1593,22 @@
}
]
},
{
"name": "pointer-demo-1",
"type": "registry:example",
"title": "Pointer Demo 1",
"description": "Example showing a pointer effect component",
"registryDependencies": [
"https://magicui.design/r/pointer"
],
"files": [
{
"path": "registry/example/pointer-demo-1.tsx",
"type": "registry:example",
"target": "components/pointer-demo-1.tsx"
}
]
},
{
"name": "neon-gradient-card-demo",
"type": "registry:example",
Expand Down
15 changes: 15 additions & 0 deletions registry/example/pointer-demo-1.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"use client";

import { PointerWrapper } from "@/registry/magicui/pointer";

export default function PointerDemo1() {
return (
<PointerWrapper>
<div className="relative flex size-[500px] items-center justify-center overflow-hidden rounded-lg border bg-background">
<span className="pointer-events-none whitespace-pre-wrap bg-gradient-to-b from-black to-gray-300/80 bg-clip-text text-center text-8xl font-semibold leading-none text-transparent dark:from-white dark:to-slate-900/10">
Pointer
</span>
</div>
</PointerWrapper>
);
}
136 changes: 136 additions & 0 deletions registry/magicui/pointer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
"use client";

import { cn } from "@/lib/utils";
import { AnimatePresence, motion, useMotionValue } from "motion/react";
import React, { useEffect, useRef, useState } from "react";

/**
* @property {React.ReactNode} children - The child elements to be wrapped
*/
interface PointerWrapperProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
}

/**
* A component that wraps content and adds a custom pointer animation when hovering
* over the wrapped area. The pointer follows the mouse movement within the wrapped area.
*
* @component
* @param {PointerWrapperProps} props - The component props
*/
export function PointerWrapper({
children,
className,
...props
}: PointerWrapperProps) {
const x = useMotionValue(0);
const y = useMotionValue(0);

const ref = useRef<HTMLDivElement>(null);
const [rect, setRect] = useState<DOMRect | null>(null);
const [isInside, setIsInside] = useState<boolean>(false);

useEffect(() => {
function updateRect() {
if (ref.current) {
setRect(ref.current.getBoundingClientRect());
}
}

// Initial rect calculation
updateRect();

// Update rect on window resize
window.addEventListener("resize", updateRect);

return () => {
window.removeEventListener("resize", updateRect);
};
}, []);

function handleMouseMove(e: React.MouseEvent<HTMLDivElement>) {
if (rect) {
x.set(e.clientX - rect.left);
y.set(e.clientY - rect.top);
}
}

function handleMouseLeave() {
setIsInside(false);
}

function handleMouseEnter() {
if (ref.current) {
setRect(ref.current.getBoundingClientRect());
setIsInside(true);
}
}

return (
<div
ref={ref}
className={cn("relative cursor-none", className)}
onMouseLeave={handleMouseLeave}
onMouseEnter={handleMouseEnter}
onMouseMove={handleMouseMove}
{...props}
>
<AnimatePresence>{isInside && <Pointer x={x} y={y} />}</AnimatePresence>
{children}
</div>
);
}

/**
* @property {MotionValue<number>} x - The x-coordinate position of the pointer
* @property {MotionValue<number>} y - The y-coordinate position of the pointer
*/
interface PointerProps {
x: any;
y: any;
}

/**
* A custom pointer component that displays an animated arrow cursor
*
* @description Used internally by PointerWrapper to show the custom cursor
*
* @component
* @param {PointerProps} props - The component props
*/
function Pointer({ x, y }: PointerProps): JSX.Element {
return (
<motion.div
className="pointer-events-none absolute z-50 h-4 w-4 rounded-full"
style={{
top: y,
left: x,
}}
initial={{
scale: 1,
opacity: 1,
}}
animate={{
scale: 1,
opacity: 1,
}}
exit={{
scale: 0,
opacity: 0,
}}
>
<svg
stroke="currentColor"
fill="currentColor"
strokeWidth="1"
viewBox="0 0 16 16"
className="h-6 w-6 translate-x-[-12px] translate-y-[-10px] rotate-[-70deg] stroke-white text-black"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M14.082 2.182a.5.5 0 0 1 .103.557L8.528 15.467a.5.5 0 0 1-.917-.007L5.57 10.694.803 8.652a.5.5 0 0 1-.006-.916l12.728-5.657a.5.5 0 0 1 .556.103z"></path>
</svg>
</motion.div>
);
}
14 changes: 14 additions & 0 deletions registry/registry-examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,20 @@ export const examples: Registry["items"] = [
},
],
},
{
name: "pointer-demo-1",
type: "registry:example",
title: "Pointer Demo 1",
description: "Example showing a pointer effect component",
registryDependencies: ["https://magicui.design/r/pointer"],
files: [
{
path: "registry/example/pointer-demo-1.tsx",
type: "registry:example",
target: "components/pointer-demo-1.tsx",
},
],
},
{
name: "neon-gradient-card-demo",
type: "registry:example",
Expand Down
Loading

0 comments on commit 27ff381

Please sign in to comment.