From 4a4857ab4e41c9d34bc199404201825c3b662e7f Mon Sep 17 00:00:00 2001 From: nayanrdeveloper Date: Thu, 4 Sep 2025 17:27:12 +0530 Subject: [PATCH 1/3] text to circle animation added --- .../chevron-steps/chevron-steps-basic.tsx | 20 ++ .../chevron-steps/chevron-steps-neutral.tsx | 16 ++ .../chevron-steps/chevron-steps-progress.tsx | 38 +++ app/docs/chevron-steps/page.mdx | 74 ++++++ app/docs/navigation.ts | 3 + app/docs/text-circle-scroll/page.mdx | 71 ++++++ .../text-circle-scroll/text-circle-auto.tsx | 23 ++ .../text-circle-scroll/text-circle-basic.tsx | 33 +++ .../text-circle-scroll/text-circle-nodes.tsx | 30 +++ components/core/chevron-steps.tsx | 223 ++++++++++++++++++ components/core/leaderboard-card.tsx | 2 +- components/core/text-circle-scroll.tsx | 173 ++++++++++++++ scripts/registry-components.ts | 14 ++ scripts/registry-examples.ts | 68 ++++++ stories/core/ChevronSteps.stories.tsx | 196 +++++++++++++++ stories/core/TextCircleScroll.stories.tsx | 151 ++++++++++++ 16 files changed, 1134 insertions(+), 1 deletion(-) create mode 100644 app/docs/chevron-steps/chevron-steps-basic.tsx create mode 100644 app/docs/chevron-steps/chevron-steps-neutral.tsx create mode 100644 app/docs/chevron-steps/chevron-steps-progress.tsx create mode 100644 app/docs/chevron-steps/page.mdx create mode 100644 app/docs/text-circle-scroll/page.mdx create mode 100644 app/docs/text-circle-scroll/text-circle-auto.tsx create mode 100644 app/docs/text-circle-scroll/text-circle-basic.tsx create mode 100644 app/docs/text-circle-scroll/text-circle-nodes.tsx create mode 100644 components/core/chevron-steps.tsx create mode 100644 components/core/text-circle-scroll.tsx create mode 100644 stories/core/ChevronSteps.stories.tsx create mode 100644 stories/core/TextCircleScroll.stories.tsx diff --git a/app/docs/chevron-steps/chevron-steps-basic.tsx b/app/docs/chevron-steps/chevron-steps-basic.tsx new file mode 100644 index 0000000..5ce40c8 --- /dev/null +++ b/app/docs/chevron-steps/chevron-steps-basic.tsx @@ -0,0 +1,20 @@ +import ChevronSteps from '@/components/core/chevron-steps'; + +export function ChevronStepsBasic() { + return ( +
+ +
+ ); +} diff --git a/app/docs/chevron-steps/chevron-steps-neutral.tsx b/app/docs/chevron-steps/chevron-steps-neutral.tsx new file mode 100644 index 0000000..294aa88 --- /dev/null +++ b/app/docs/chevron-steps/chevron-steps-neutral.tsx @@ -0,0 +1,16 @@ +import ChevronSteps from '@/components/core/chevron-steps'; + +export function ChevronStepsNeutral() { + return ( +
+ +
+ ); +} diff --git a/app/docs/chevron-steps/chevron-steps-progress.tsx b/app/docs/chevron-steps/chevron-steps-progress.tsx new file mode 100644 index 0000000..d3ffbac --- /dev/null +++ b/app/docs/chevron-steps/chevron-steps-progress.tsx @@ -0,0 +1,38 @@ +'use client'; +import * as React from 'react'; +import ChevronSteps from '@/components/core/chevron-steps'; + +export function ChevronStepsProgress() { + const [idx, setIdx] = React.useState(2); + return ( +
+ +
+ + +
+
+ ); +} diff --git a/app/docs/chevron-steps/page.mdx b/app/docs/chevron-steps/page.mdx new file mode 100644 index 0000000..cddc5dd --- /dev/null +++ b/app/docs/chevron-steps/page.mdx @@ -0,0 +1,74 @@ +export const metadata = { + title: 'Chevron Steps – Shadcn-Extras', + description: + 'Arrowed step progress bar with clickable segments. Accessible, responsive, and fully customizable.', +}; + +import ComponentCodePreview from '@/components/website/component-code-preview'; +import { ChevronStepsBasic } from './chevron-steps-basic'; +import { ChevronStepsProgress } from './chevron-steps-progress'; +import { ChevronStepsNeutral } from './chevron-steps-neutral'; + +# Chevron Steps + +A clean chevron-style stepper with arrow tails. Great for multi-step forms and wizards. +Keyboard and screen-reader friendly, responsive, and customizable via props. + +## Examples + +### Brand + +} + filePath="app/docs/chevron-steps/chevron-steps-basic.tsx" +/> + +### Clickable progress + +} + filePath="app/docs/chevron-steps/chevron-steps-progress.tsx" +/> + +### Neutral, large + +} + filePath="app/docs/chevron-steps/chevron-steps-neutral.tsx" +/> + +## Installation + + + + CLI + Manual + + + + + + + + + Copy the component into components/core/chevron-steps.tsx. + + Use with your step data. Control the active index with the current prop. + + + + +## Props + +| Prop | Type | Default | Description | +| :-- | :-- | :-- | :-- | +| `steps` | `Array<{ id?: string\|number; label: string; description?: string; className?: string; disabled?: boolean }>` | — | Steps in order. | +| `current` | `number` | `0` | Zero-based active step index. | +| `onStepClick` | `(index, step) => void` | — | Called when a step is clicked (if not disabled). | +| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Height & text size. | +| `variant` | `'brand' \| 'neutral'` | `'brand'` | Color scheme. | +| `tailWidth` | `number` | `18` | Arrow head width (px). | +| `radius` | `'md' \| 'lg' \| 'xl' \| '2xl'` | `'2xl'` | Outer roundness. | +| `scrollable` | `boolean` | `true` | Enables horizontal scroll on small screens. | +| `className` | `string` | — | Root classes. | +| `stepClassName` / `stepActiveClassName` / `stepCompletedClassName` / `stepUpcomingClassName` | `string` | — | Slot overrides. | diff --git a/app/docs/navigation.ts b/app/docs/navigation.ts index c540da2..516948b 100644 --- a/app/docs/navigation.ts +++ b/app/docs/navigation.ts @@ -29,6 +29,9 @@ export const NAVIGATION: NavigationGroup[] = [ children: [ { name: 'Timeline Rail', href: '/docs/timeline-rail', isNew: true }, { name: 'Testimonial', href: '/docs/testimonial', isUpdated: true }, + { name: 'Chevron Steps', href: '/docs/chevron-steps', isNew: true }, +{ name: 'Text Circle Scroll', href: '/docs/text-circle-scroll', isNew: true }, + ], }, { diff --git a/app/docs/text-circle-scroll/page.mdx b/app/docs/text-circle-scroll/page.mdx new file mode 100644 index 0000000..ae31637 --- /dev/null +++ b/app/docs/text-circle-scroll/page.mdx @@ -0,0 +1,71 @@ +export const metadata = { + title: 'Text Circle Scroll – Shadcn-Extras', + description: + 'Lay text (or any nodes) around a circle and rotate it with scroll and/or auto-spin. Fully customizable and accessible.', +}; + +import ComponentCodePreview from '@/components/website/component-code-preview'; +import { TextCircleBasic } from './text-circle-basic'; +import { TextCircleAuto } from './text-circle-auto'; +import { TextCircleNodes } from './text-circle-nodes'; + +# Text Circle Scroll + +Arrange any items around a circle and rotate the ring based on **scroll progress** (with spring), **auto-spin**, or a **combination of both**. + +## Examples + +### Scroll-driven +} + filePath="app/docs/text-circle-scroll/text-circle-basic.tsx" +/> + +### Auto-spin (no scroll) +} + filePath="app/docs/text-circle-scroll/text-circle-auto.tsx" +/> + +### Using custom nodes +} + filePath="app/docs/text-circle-scroll/text-circle-nodes.tsx" +/> + +## Installation + + + + CLI + Manual + + + + + + + + + Copy the component into components/core/text-circle-scroll.tsx. + + Pass your items, radius, and behaviors (scroll/auto-spin) via props. + + + + +## Props + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `items` | `(string \| ReactNode)[]` | — | Items around the ring in order. | +| `radius` | `number` | `110` | Radius in px (center → baseline). | +| `innerGap` | `number` | `90` | Visual hole size (px). | +| `startAngle` | `number` | `-90` | First item angle in degrees. | +| `clockwise` | `boolean` | `true` | Direction of item order. | +| `rotateOnScroll` | `boolean` | `true` | Attach rotation to scroll. | +| `scrollDegrees` | `number` | `360` | Total degrees across viewport scrolling. | +| `autoSpinDegPerSec` | `number` | `0` | Constant rotation (deg/s). | +| `springStiffness` | `number` | `120` | Spring stiffness for scroll rotation. | +| `height` | `number \| string` | `320` | Outer container height. | +| `className` / `ringClassName` / `itemClassName` / `textClassName` | `string` | — | Slot overrides. | diff --git a/app/docs/text-circle-scroll/text-circle-auto.tsx b/app/docs/text-circle-scroll/text-circle-auto.tsx new file mode 100644 index 0000000..14dc8d4 --- /dev/null +++ b/app/docs/text-circle-scroll/text-circle-auto.tsx @@ -0,0 +1,23 @@ +import TextCircleScroll from '@/components/core/text-circle-scroll'; + +export function TextCircleAuto() { + const words = Array.from({ length: 16 }, (_, i) => + (['Aurora', 'Lullaby', 'Labyrinth', 'Idyllic', 'Felicity'] as const)[i % 5] + ); + + return ( +
+ +
+ ); +} \ No newline at end of file diff --git a/app/docs/text-circle-scroll/text-circle-basic.tsx b/app/docs/text-circle-scroll/text-circle-basic.tsx new file mode 100644 index 0000000..e15f7f5 --- /dev/null +++ b/app/docs/text-circle-scroll/text-circle-basic.tsx @@ -0,0 +1,33 @@ +import TextCircleScroll from '@/components/core/text-circle-scroll'; + +const WORDS = [ + 'Bungalow', + 'Aurora', + 'Lullaby', + 'Labyrinth', + 'Idyllic', + 'Felicity', + 'Demure', + 'Chatoyant', + 'TheseDays', + 'Demure', + 'Aurora', + 'Bungalow', +]; + +export function TextCircleBasic() { + return ( +
+ +
+ ); +} diff --git a/app/docs/text-circle-scroll/text-circle-nodes.tsx b/app/docs/text-circle-scroll/text-circle-nodes.tsx new file mode 100644 index 0000000..05f24ba --- /dev/null +++ b/app/docs/text-circle-scroll/text-circle-nodes.tsx @@ -0,0 +1,30 @@ +import TextCircleScroll from '@/components/core/text-circle-scroll'; + +export function TextCircleNodes() { + const items = [ + Design, + Motion, + UI, + DX, + A11y, + Next.js, + Tailwind, + Framer, + ]; + + return ( +
+ +
+ ); +} diff --git a/components/core/chevron-steps.tsx b/components/core/chevron-steps.tsx new file mode 100644 index 0000000..45f864b --- /dev/null +++ b/components/core/chevron-steps.tsx @@ -0,0 +1,223 @@ +'use client'; + +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +export type ChevronStep = { + id?: string | number; + label: string; + /** Optional accessible description (shown via title attr). */ + description?: string; + /** Optional custom classes per step. */ + className?: string; + /** Disable click for this step. */ + disabled?: boolean; +}; + +export type ChevronStepsProps = { + /** Steps in order. */ + steps: ChevronStep[]; + + /** Zero-based current step index. */ + current?: number; + + /** Called when a (non-disabled) step is clicked. */ + onStepClick?: (index: number, step: ChevronStep) => void; + + /** Sizes & look */ + size?: 'sm' | 'md' | 'lg'; + variant?: 'brand' | 'neutral'; + + /** Tail (arrow) width in px (CSS variable) */ + tailWidth?: number; + + /** Roundness on the bar */ + radius?: 'md' | 'lg' | 'xl' | '2xl'; + + /** Allow horizontal scroll on small screens */ + scrollable?: boolean; + + /** Root class */ + className?: string; + + /** Slot overrides */ + stepClassName?: string; + stepActiveClassName?: string; + stepCompletedClassName?: string; + stepUpcomingClassName?: string; +}; + +/** + * ChevronSteps: horizontally joined chevron items with a configurable arrow tail, + * a11y-friendly (list+buttons), and simple API. + */ +export default function ChevronSteps({ + steps, + current = 0, + onStepClick, + size = 'md', + variant = 'brand', + tailWidth = 18, + radius = '2xl', + scrollable = true, + className, + stepClassName, + stepActiveClassName, + stepCompletedClassName, + stepUpcomingClassName, +}: ChevronStepsProps) { + const h = + size === 'sm' ? 'h-8 text-xs' : size === 'lg' ? 'h-14 text-base' : 'h-11 text-sm'; + const pad = size === 'sm' ? 'px-4' : size === 'lg' ? 'px-7' : 'px-6'; + const r = + radius === 'md' + ? 'rounded-md' + : radius === 'lg' + ? 'rounded-lg' + : radius === 'xl' + ? 'rounded-xl' + : 'rounded-2xl'; + + // The chevron shape uses a clip-path polygon with --twc-tail custom property + // to create the arrow head. We mask the first/last to give rounded ends. + const baseStep = + 'relative isolate flex grow select-none items-center justify-center whitespace-nowrap font-medium transition-colors'; + + const theme = + variant === 'brand' + ? { + active: + 'bg-blue-700 text-white', + completed: + 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-200', + upcoming: + 'bg-zinc-100 text-zinc-600 dark:bg-zinc-900 dark:text-zinc-400', + border: 'ring-1 ring-white/70 dark:ring-black/20', + } + : { + active: + 'bg-zinc-800 text-white dark:bg-zinc-200 dark:text-zinc-900', + completed: + 'bg-zinc-200 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200', + upcoming: + 'bg-zinc-100 text-zinc-600 dark:bg-zinc-900 dark:text-zinc-400', + border: 'ring-1 ring-white/70 dark:ring-black/20', + }; + + const containerClasses = cn( + 'relative', + r, + scrollable ? 'overflow-x-auto' : 'overflow-hidden', + 'bg-transparent', + className + ); + + return ( +
+
    + {steps.map((step, i) => { + const state = + i < current ? 'completed' : i === current ? 'active' : 'upcoming'; + + const color = + state === 'active' + ? theme.active + : state === 'completed' + ? theme.completed + : theme.upcoming; + + const override = + state === 'active' + ? stepActiveClassName + : state === 'completed' + ? stepCompletedClassName + : stepUpcomingClassName; + + // leftmost and rightmost need rounded masks + const roundLeft = i === 0 ? r : ''; + const roundRight = i === steps.length - 1 ? r : ''; + + // Each step uses clip-path polygon to form a chevron. + // We add a tiny overlap (-0.5px) to avoid hairline gaps between shapes. + return ( +
  1. + +
  2. + ); + })} +
+
+ ); +} diff --git a/components/core/leaderboard-card.tsx b/components/core/leaderboard-card.tsx index af36acb..6125b79 100644 --- a/components/core/leaderboard-card.tsx +++ b/components/core/leaderboard-card.tsx @@ -136,7 +136,7 @@ export function LeaderboardCard({ className={cn( 'relative inline-flex items-center justify-center rounded-full ring-4 ring-white dark:ring-zinc-900', s.avatar, - avatarRingClassName ?? 'outline outline-4 outline-yellow-300/80' + avatarRingClassName ?? 'outline outline-yellow-300/80' )} > {avatarSrc ? ( diff --git a/components/core/text-circle-scroll.tsx b/components/core/text-circle-scroll.tsx new file mode 100644 index 0000000..f0e469e --- /dev/null +++ b/components/core/text-circle-scroll.tsx @@ -0,0 +1,173 @@ +'use client'; + +import * as React from 'react'; +import { motion, useScroll, useTransform, useSpring } from 'motion/react'; +import { cn } from '@/lib/utils'; + +export type TextCircleItem = + | string + | React.ReactNode; + +export type TextCircleScrollProps = { + /** Items placed on the ring in order (clockwise by default). */ + items: TextCircleItem[]; + + /** Radius in px (distance from center to baseline of items). */ + radius?: number; + + /** Empty hole diameter in px (purely visual helper class on container). */ + innerGap?: number; + + /** Angle offset in degrees for the first item. */ + startAngle?: number; + + /** Clockwise (true) or counter-clockwise (false) item order. */ + clockwise?: boolean; + + /** If true, connects rotation to page scroll progress. */ + rotateOnScroll?: boolean; + + /** + * How many degrees the ring rotates from top->bottom of the page when + * rotateOnScroll = true. 360 = one full turn. + */ + scrollDegrees?: number; + + /** Constant rotation (deg/s). Can be negative for reverse. */ + autoSpinDegPerSec?: number; + + /** Springiness for scroll rotation. Set 0 to disable spring. */ + springStiffness?: number; + + /** Container className */ + className?: string; + + /** Ring element className */ + ringClassName?: string; + + /** Per-item className */ + itemClassName?: string; + + /** Typography class for items (e.g. 'text-lg font-serif') */ + textClassName?: string; + + /** If provided, constrain the component height (useful in docs/examples). */ + height?: number | string; +}; + +/** + * TextCircleScroll + * - Places any text/nodes around a circle + * - Rotates with scroll (and/or auto-spin) + * - Purely presentational; does not manage layout outside itself + */ +export default function TextCircleScroll({ + items, + radius = 110, + innerGap = 90, + startAngle = -90, + clockwise = true, + rotateOnScroll = true, + scrollDegrees = 360, + autoSpinDegPerSec = 0, + springStiffness = 120, + className, + ringClassName, + itemClassName, + textClassName = 'text-base font-serif text-zinc-800 dark:text-zinc-200', + height = 320, +}: TextCircleScrollProps) { + const ref = React.useRef(null); + + // --- Scroll-driven rotation + const { scrollYProgress } = useScroll({ + // when the component is within the viewport + target: ref, + offset: ['start end', 'end start'], + }); + + const rawRotate = useTransform(scrollYProgress, [0, 1], [0, scrollDegrees]); + const rotateSpring = useSpring(rawRotate, { + stiffness: springStiffness || 120, + damping: 18, + mass: 0.6, + }); + + // --- Auto-spin via requestAnimationFrame + const [autoAngle, setAutoAngle] = React.useState(0); + React.useEffect(() => { + if (!autoSpinDegPerSec) return; + let raf = 0; + let last = performance.now(); + const tick = (t: number) => { + const dt = (t - last) / 1000; + last = t; + setAutoAngle((a) => a + autoSpinDegPerSec * dt); + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [autoSpinDegPerSec]); + + // Combine scroll rotation + auto rotation. + const [combined, setCombined] = React.useState(0); + React.useEffect(() => { + const unsub = rotateSpring.on('change', (v) => setCombined(v)); + return () => unsub(); + }, [rotateSpring]); + + const totalRotation = (rotateOnScroll ? combined : 0) + autoAngle; + + const n = items.length; + const step = 360 / Math.max(1, n); + const dir = clockwise ? 1 : -1; + + return ( +
+ {/* Visual inner hole helper */} +
+ {/* Ring */} + + {items.map((item, i) => { + const angle = startAngle + dir * i * step; + // Place at angle: rotate container then translate then un-rotate + const style: React.CSSProperties = { + position: 'absolute', + top: '50%', + left: '50%', + transform: `rotate(${angle}deg) translate(${radius}px) rotate(${clockwise ? 90 : -90}deg)`, + transformOrigin: '0 0', + whiteSpace: 'nowrap', + }; + return ( +
+ + {typeof item === 'string' ? item : item} + +
+ ); + })} +
+
+ ); +} diff --git a/scripts/registry-components.ts b/scripts/registry-components.ts index b1bdf7c..697deb4 100644 --- a/scripts/registry-components.ts +++ b/scripts/registry-components.ts @@ -129,4 +129,18 @@ export const components: ComponentDefinition[] = [ description: 'Outlined pricing card with colored frame, icon, price and feature list.', }, + { + name: 'chevron-steps', + path: path.join(__dirname, '../components/core/chevron-steps.tsx'), + registryDependencies: [], + dependencies: [], + description: 'Arrowed step progress bar with clickable segments.' +}, +{ + name: 'text-circle-scroll', + path: path.join(__dirname, '../components/core/text-circle-scroll.tsx'), + registryDependencies: [], + dependencies: ['motion'], + description: 'Circular text/nodes that rotate with scroll and/or auto-spin.', +}, ]; diff --git a/scripts/registry-examples.ts b/scripts/registry-examples.ts index f2079d3..1510cd0 100644 --- a/scripts/registry-examples.ts +++ b/scripts/registry-examples.ts @@ -456,4 +456,72 @@ export const examples: ExampleDefinition[] = [ }, ], }, + { + name: 'chevron-steps-basic', + path: path.join(__dirname, '../app/docs/chevron-steps/chevron-steps-basic.tsx'), + description: 'Brand variant like the reference screenshot.', + componentName: 'chevron-steps-basic', + files: [ + { name: 'chevron-steps.tsx', path: path.join(__dirname, '../components/core/chevron-steps.tsx'), type: 'registry:ui' }, + ], +}, +{ + name: 'chevron-steps-progress', + path: path.join(__dirname, '../app/docs/chevron-steps/chevron-steps-progress.tsx'), + description: 'Interactive progress with Prev/Next.', + componentName: 'chevron-steps-progress', + files: [ + { name: 'chevron-steps.tsx', path: path.join(__dirname, '../components/core/chevron-steps.tsx'), type: 'registry:ui' }, + ], +}, +{ + name: 'chevron-steps-neutral', + path: path.join(__dirname, '../app/docs/chevron-steps/chevron-steps-neutral.tsx'), + description: 'Neutral, large variant.', + componentName: 'chevron-steps-neutral', + files: [ + { name: 'chevron-steps.tsx', path: path.join(__dirname, '../components/core/chevron-steps.tsx'), type: 'registry:ui' }, + ], +}, +{ + name: 'text-circle-scroll-basic', + path: path.join(__dirname, '../app/docs/text-circle-scroll/text-circle-basic.tsx'), + description: 'Scroll-driven rotation with serif words around the ring.', + componentName: 'text-circle-basic', + files: [ + { + name: 'text-circle-scroll.tsx', + path: path.join(__dirname, '../components/core/text-circle-scroll.tsx'), + type: 'registry:ui', + }, + ], + }, + { + name: 'text-circle-scroll-auto', + path: path.join(__dirname, '../app/docs/text-circle-scroll/text-circle-auto.tsx'), + description: 'Auto-spin demo (no scroll binding), counter-clockwise.', + componentName: 'text-circle-auto', + files: [ + { + name: 'text-circle-scroll.tsx', + path: path.join(__dirname, '../components/core/text-circle-scroll.tsx'), + type: 'registry:ui', + }, + ], + }, + { + name: 'text-circle-scroll-nodes', + path: path.join(__dirname, '../app/docs/text-circle-scroll/text-circle-nodes.tsx'), + description: 'Custom React nodes (uppercase labels) + mixed scroll/auto.', + componentName: 'text-circle-nodes', + files: [ + { + name: 'text-circle-scroll.tsx', + path: path.join(__dirname, '../components/core/text-circle-scroll.tsx'), + type: 'registry:ui', + }, + ], + }, + + ]; diff --git a/stories/core/ChevronSteps.stories.tsx b/stories/core/ChevronSteps.stories.tsx new file mode 100644 index 0000000..030a447 --- /dev/null +++ b/stories/core/ChevronSteps.stories.tsx @@ -0,0 +1,196 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import * as React from 'react'; +import ChevronSteps from '@/components/core/chevron-steps'; +import type { ChevronStep } from '@/components/core/chevron-steps'; +import { action } from '@storybook/addon-actions'; + +const stepClicked = action('step-click') as (index: number, step: ChevronStep) => void; + +const meta: Meta = { + title: 'Core/Navigation/Chevron Steps', + component: ChevronSteps, + tags: ['autodocs'], + parameters: { + layout: 'padded', + docs: { + description: { + component: + 'Chevron-style stepper with arrow tails. Accessible (list + `aria-current="step"`), responsive, and fully customizable. Presentational (SRP) and controlled via props.', + }, + }, + }, + argTypes: { + steps: { control: 'object', description: 'Array of steps in order.' }, + current: { control: { type: 'number', min: 0 }, description: 'Zero-based active index.' }, + size: { control: 'inline-radio', options: ['sm', 'md', 'lg'] }, + variant: { control: 'inline-radio', options: ['brand', 'neutral'] }, + radius: { control: 'inline-radio', options: ['md', 'lg', 'xl', '2xl'] }, + tailWidth: { control: { type: 'range', min: 10, max: 32, step: 1 } }, + scrollable: { control: 'boolean' }, + className: { control: 'text' }, + stepClassName: { control: 'text' }, + stepActiveClassName: { control: 'text' }, + stepCompletedClassName: { control: 'text' }, + stepUpcomingClassName: { control: 'text' }, + onStepClick: { action: 'onStepClick', table: { disable: true } }, + }, +}; +export default meta; + +type Story = StoryObj; + +const basicSteps: ChevronStep[] = [ + { label: 'Step 1' }, + { label: 'Step 2 some words' }, + { label: 'Step 3' }, + { label: 'Step 4' }, +]; + +/* --------------------------------------------------------------- */ +/* Playground with controls */ +/* --------------------------------------------------------------- */ + +export const Playground: Story = { + args: { + steps: basicSteps, + current: 0, + size: 'md', + variant: 'brand', + radius: '2xl', + tailWidth: 20, + scrollable: true, + onStepClick: stepClicked, + className: 'mx-auto max-w-5xl', + }, +}; + +/* --------------------------------------------------------------- */ +/* Screenshot-like (brand) */ +/* --------------------------------------------------------------- */ + +export const LikeScreenshot: Story = { + render: () => ( +
+ +
+ ), +}; + +/* --------------------------------------------------------------- */ +/* Neutral Large */ +/* --------------------------------------------------------------- */ + +export const NeutralLarge: Story = { + render: () => ( +
+ +
+ ), +}; + +/* --------------------------------------------------------------- */ +/* Many steps + scroll */ +/* --------------------------------------------------------------- */ + +export const ScrollableMany: Story = { + render: () => ( +
+ ({ + label: i === 3 ? `Step ${i + 1} with long label` : `Step ${i + 1}`, + }))} + current={4} + size="md" + tailWidth={18} + onStepClick={stepClicked} + /> +
+ ), +}; + +/* --------------------------------------------------------------- */ +/* With a disabled step */ +/* --------------------------------------------------------------- */ + +export const WithDisabled: Story = { + render: () => ( +
+ +
+ ), +}; + +const InteractiveDemo = (args: React.ComponentProps) => { + const [idx, setIdx] = React.useState(1); + const steps: ChevronStep[] = [ + { label: 'Account' }, + { label: 'Details' }, + { label: 'Verify' }, + { label: 'Done' }, + ]; + + return ( +
+ { + stepClicked(i, s); + if (!s.disabled) setIdx(i); + }} + /> +
+ + +
+
+ ); +}; + + +/* --------------------------------------------------------------- */ +/* Interactive (stateful demo) */ +/* --------------------------------------------------------------- */ + +export const Interactive: Story = { + render: (args) => , + args: { + size: 'md', + variant: 'brand', + tailWidth: 20, + }, +} diff --git a/stories/core/TextCircleScroll.stories.tsx b/stories/core/TextCircleScroll.stories.tsx new file mode 100644 index 0000000..a769889 --- /dev/null +++ b/stories/core/TextCircleScroll.stories.tsx @@ -0,0 +1,151 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import * as React from 'react'; +import TextCircleScroll from '@/components/core/text-circle-scroll'; +import type { TextCircleScrollProps } from '@/components/core/text-circle-scroll'; + +const WORDS = [ + 'Bungalow', + 'Aurora', + 'Lullaby', + 'Labyrinth', + 'Idyllic', + 'Felicity', + 'Demure', + 'Chatoyant', + 'TheseDays', + 'Demure', + 'Aurora', + 'Bungalow', +]; + +const meta: Meta = { + title: 'Core/Animation/Text Circle Scroll', + component: TextCircleScroll, + tags: ['autodocs'], + parameters: { + layout: 'padded', + docs: { + description: { + component: + 'Arrange any text/nodes around a circle and rotate with page scroll and/or auto-spin. Fully presentational (SRP) and customizable.', + }, + }, + }, + argTypes: { + items: { control: 'object' }, + radius: { control: { type: 'range', min: 60, max: 200, step: 2 } }, + innerGap: { control: { type: 'range', min: 40, max: 160, step: 2 } }, + startAngle: { control: { type: 'range', min: -180, max: 180, step: 1 } }, + clockwise: { control: 'boolean' }, + rotateOnScroll: { control: 'boolean' }, + scrollDegrees: { control: { type: 'range', min: -720, max: 720, step: 10 } }, + autoSpinDegPerSec: { control: { type: 'range', min: -60, max: 60, step: 1 } }, + springStiffness: { control: { type: 'range', min: 0, max: 300, step: 10 } }, + height: { control: 'text' }, + className: { control: 'text' }, + ringClassName: { control: 'text' }, + itemClassName: { control: 'text' }, + textClassName: { control: 'text' }, + }, +}; + +export default meta; +type Story = StoryObj; + +/* --------------------------------------------------------------- */ +/* Playground with controls */ +/* --------------------------------------------------------------- */ + +export const Playground: Story = { + render: (args) => ( +
+ +
+ ), + args: { + items: WORDS, + radius: 110, + innerGap: 90, + startAngle: -90, + clockwise: true, + rotateOnScroll: true, + scrollDegrees: 300, + autoSpinDegPerSec: 0, + springStiffness: 120, + height: 340, + textClassName: 'text-[18px] font-serif text-zinc-800 dark:text-zinc-100', + } satisfies TextCircleScrollProps, +}; + +/* --------------------------------------------------------------- */ +/* Like screenshot: scroll-driven serif words */ +/* --------------------------------------------------------------- */ + +export const ScrollDriven: Story = { + render: () => ( +
+ +
+ ), +}; + +/* --------------------------------------------------------------- */ +/* Auto-spin only (counter-clockwise) */ +/* --------------------------------------------------------------- */ + +export const AutoSpin: Story = { + render: () => ( +
+ + (['Aurora', 'Lullaby', 'Labyrinth', 'Idyllic', 'Felicity'] as const)[i % 5] + )} + radius={120} + innerGap={80} + startAngle={-60} + clockwise={false} + rotateOnScroll={false} + autoSpinDegPerSec={18} + textClassName="text-[17px] tracking-wide text-zinc-700 dark:text-zinc-100" + /> +
+ ), +}; + +/* --------------------------------------------------------------- */ +/* Custom React nodes around the circle */ +/* --------------------------------------------------------------- */ + +export const CustomNodes: Story = { + render: () => ( +
+ Design, + Motion, + UI, + DX, + A11y, + Next.js, + Tailwind, + Framer, + ]} + radius={95} + innerGap={70} + startAngle={-90} + rotateOnScroll + autoSpinDegPerSec={10} + scrollDegrees={180} + textClassName="text-[14px] uppercase tracking-[0.18em] text-sky-700 dark:text-sky-200" + /> +
+ ), +}; From b1b628ab2e71c764b5bacdf967f801905c091485 Mon Sep 17 00:00:00 2001 From: nayanrdeveloper Date: Thu, 4 Sep 2025 17:37:06 +0530 Subject: [PATCH 2/3] registry files added for text-circle-componenet --- .../chevron-steps/chevron-steps-basic.tsx | 6 +- .../chevron-steps/chevron-steps-neutral.tsx | 8 +- .../chevron-steps/chevron-steps-progress.tsx | 14 +-- app/docs/chevron-steps/page.mdx | 36 ++++---- app/docs/navigation.ts | 21 +++-- app/docs/text-circle-scroll/page.mdx | 41 +++++---- .../text-circle-scroll/text-circle-auto.tsx | 16 ++-- .../text-circle-scroll/text-circle-basic.tsx | 6 +- .../text-circle-scroll/text-circle-nodes.tsx | 38 +++++--- cli/package-lock.json | 4 +- cli/package.json | 2 +- components/core/chevron-steps.tsx | 40 ++++---- components/core/text-circle-scroll.tsx | 16 ++-- public/c/chevron-steps.json | 19 ++++ public/c/leaderboard-card.json | 2 +- public/c/registry.json | 66 ++++++++++--- public/c/text-circle-scroll.json | 21 +++++ public/e/chevron-steps-basic.json | 18 ++++ public/e/chevron-steps-neutral.json | 18 ++++ public/e/chevron-steps-progress.json | 18 ++++ public/e/leaderboard-first.json | 2 +- public/e/leaderboard-second.json | 2 +- public/e/text-circle-scroll-auto.json | 18 ++++ public/e/text-circle-scroll-basic.json | 18 ++++ public/e/text-circle-scroll-nodes.json | 18 ++++ scripts/registry-components.ts | 27 +++--- scripts/registry-examples.ts | 92 ++++++++++++------- stories/core/ChevronSteps.stories.tsx | 39 ++++---- stories/core/TextCircleScroll.stories.tsx | 62 +++++++++---- 29 files changed, 482 insertions(+), 206 deletions(-) create mode 100644 public/c/chevron-steps.json create mode 100644 public/c/text-circle-scroll.json create mode 100644 public/e/chevron-steps-basic.json create mode 100644 public/e/chevron-steps-neutral.json create mode 100644 public/e/chevron-steps-progress.json create mode 100644 public/e/text-circle-scroll-auto.json create mode 100644 public/e/text-circle-scroll-basic.json create mode 100644 public/e/text-circle-scroll-nodes.json diff --git a/app/docs/chevron-steps/chevron-steps-basic.tsx b/app/docs/chevron-steps/chevron-steps-basic.tsx index 5ce40c8..c19a876 100644 --- a/app/docs/chevron-steps/chevron-steps-basic.tsx +++ b/app/docs/chevron-steps/chevron-steps-basic.tsx @@ -2,7 +2,7 @@ import ChevronSteps from '@/components/core/chevron-steps'; export function ChevronStepsBasic() { return ( -
+
); diff --git a/app/docs/chevron-steps/chevron-steps-neutral.tsx b/app/docs/chevron-steps/chevron-steps-neutral.tsx index 294aa88..80697e8 100644 --- a/app/docs/chevron-steps/chevron-steps-neutral.tsx +++ b/app/docs/chevron-steps/chevron-steps-neutral.tsx @@ -2,14 +2,14 @@ import ChevronSteps from '@/components/core/chevron-steps'; export function ChevronStepsNeutral() { return ( -
+
); diff --git a/app/docs/chevron-steps/chevron-steps-progress.tsx b/app/docs/chevron-steps/chevron-steps-progress.tsx index d3ffbac..a650a31 100644 --- a/app/docs/chevron-steps/chevron-steps-progress.tsx +++ b/app/docs/chevron-steps/chevron-steps-progress.tsx @@ -5,7 +5,7 @@ import ChevronSteps from '@/components/core/chevron-steps'; export function ChevronStepsProgress() { const [idx, setIdx] = React.useState(2); return ( -
+
-
+
\n \n );\n })}\n \n
\n );\n}\n", + "type": "registry:ui" + } + ] +} \ No newline at end of file diff --git a/public/c/leaderboard-card.json b/public/c/leaderboard-card.json index b3224bc..c6bd0cb 100644 --- a/public/c/leaderboard-card.json +++ b/public/c/leaderboard-card.json @@ -14,7 +14,7 @@ "files": [ { "path": "leaderboard-card.tsx", - "content": "'use client';\n\nimport * as React from 'react';\nimport { cn } from '@/lib/utils';\nimport { Crown } from 'lucide-react';\n\ntype Tone = 'emerald' | 'blue' | 'zinc';\ntype Size = 'sm' | 'md' | 'lg';\n\nexport type LeaderboardCardProps = {\n name: string;\n amount?: number | string; // e.g. 8034 or \"$8,034\"\n amountPrefix?: string; // e.g. \"$\"\n avatarSrc?: string;\n avatarAlt?: string;\n rank?: number; // 1..n (shows small badge)\n score?: number; // 0..100 -> progress + right-aligned %\n label?: string; // left label under pill (default \"Score\")\n showCrown?: boolean; // show crown for #1\n tone?: Tone; // background + bar color\n size?: Size; // paddings/fonts\n className?: string;\n /** Optional custom avatar ring class (e.g., gradient ring) */\n avatarRingClassName?: string;\n /** Optional custom progress color class (overrides tone) */\n progressClassName?: string;\n /** Optional custom pill class */\n pillClassName?: string;\n};\n\nconst toneMap: Record<\n Tone,\n {\n card: string;\n pill: string;\n progressTrack: string;\n progressFill: string;\n name: string;\n }\n> = {\n emerald: {\n card: 'bg-emerald-50/70 dark:bg-emerald-900/20 ring-1 ring-emerald-100 dark:ring-emerald-800',\n pill: 'bg-emerald-600/90 text-emerald-50',\n progressTrack: 'bg-emerald-200/60 dark:bg-emerald-900/50',\n progressFill: 'bg-emerald-500',\n name: 'text-emerald-900 dark:text-emerald-100',\n },\n blue: {\n card: 'bg-blue-50/80 dark:bg-blue-900/20 ring-1 ring-blue-100 dark:ring-blue-800',\n pill: 'bg-blue-600/90 text-blue-50',\n progressTrack: 'bg-blue-200/60 dark:bg-blue-900/50',\n progressFill: 'bg-blue-600',\n name: 'text-blue-900 dark:text-blue-100',\n },\n zinc: {\n card: 'bg-zinc-100/70 dark:bg-zinc-900/30 ring-1 ring-zinc-200 dark:ring-zinc-800',\n pill: 'bg-zinc-800 text-zinc-50',\n progressTrack: 'bg-zinc-200/60 dark:bg-zinc-800',\n progressFill: 'bg-zinc-600',\n name: 'text-zinc-900 dark:text-zinc-100',\n },\n};\n\nconst sizeMap: Record<\n Size,\n { pad: string; name: string; pill: string; caption: string; avatar: string }\n> = {\n sm: {\n pad: 'p-4',\n name: 'text-base',\n pill: 'text-[11px] px-2.5 py-1',\n caption: 'text-xs',\n avatar: 'h-14 w-14',\n },\n md: {\n pad: 'p-6',\n name: 'text-lg',\n pill: 'text-xs px-3 py-1.5',\n caption: 'text-sm',\n avatar: 'h-16 w-16',\n },\n lg: {\n pad: 'p-8',\n name: 'text-xl',\n pill: 'text-sm px-3.5 py-1.5',\n caption: 'text-sm',\n avatar: 'h-20 w-20',\n },\n};\n\nfunction formatAmount(amount?: number | string, prefix?: string) {\n if (amount == null) return undefined;\n if (typeof amount === 'string') return amount;\n try {\n return `${prefix ?? ''}${amount.toLocaleString()}`;\n } catch {\n return `${prefix ?? ''}${amount}`;\n }\n}\n\nexport function LeaderboardCard({\n name,\n amount,\n amountPrefix = '$',\n avatarSrc,\n avatarAlt = name,\n rank,\n score = 0,\n label = 'Score',\n showCrown = rank === 1,\n tone = 'emerald',\n size = 'md',\n className,\n avatarRingClassName,\n progressClassName,\n pillClassName,\n}: LeaderboardCardProps) {\n const t = toneMap[tone];\n const s = sizeMap[size];\n const pct = Math.max(0, Math.min(100, score));\n const amountLabel = formatAmount(amount, amountPrefix);\n\n return (\n \n {/* avatar */}\n
\n
\n \n {avatarSrc ? (\n // eslint-disable-next-line @next/next/no-img-element\n \n ) : (\n
\n \n {name?.[0] ?? '?'}\n \n
\n )}\n {showCrown && (\n \n )}\n
\n {typeof rank === 'number' && (\n \n {rank}\n \n )}\n
\n\n {/* name + amount pill */}\n
\n {name}\n
\n {amountLabel && (\n \n {amountLabel}\n \n )}\n
\n\n {/* score bar */}\n
\n
\n {label}\n {pct}%\n
\n \n \n
\n
\n
\n );\n}\n", + "content": "'use client';\n\nimport * as React from 'react';\nimport { cn } from '@/lib/utils';\nimport { Crown } from 'lucide-react';\n\ntype Tone = 'emerald' | 'blue' | 'zinc';\ntype Size = 'sm' | 'md' | 'lg';\n\nexport type LeaderboardCardProps = {\n name: string;\n amount?: number | string; // e.g. 8034 or \"$8,034\"\n amountPrefix?: string; // e.g. \"$\"\n avatarSrc?: string;\n avatarAlt?: string;\n rank?: number; // 1..n (shows small badge)\n score?: number; // 0..100 -> progress + right-aligned %\n label?: string; // left label under pill (default \"Score\")\n showCrown?: boolean; // show crown for #1\n tone?: Tone; // background + bar color\n size?: Size; // paddings/fonts\n className?: string;\n /** Optional custom avatar ring class (e.g., gradient ring) */\n avatarRingClassName?: string;\n /** Optional custom progress color class (overrides tone) */\n progressClassName?: string;\n /** Optional custom pill class */\n pillClassName?: string;\n};\n\nconst toneMap: Record<\n Tone,\n {\n card: string;\n pill: string;\n progressTrack: string;\n progressFill: string;\n name: string;\n }\n> = {\n emerald: {\n card: 'bg-emerald-50/70 dark:bg-emerald-900/20 ring-1 ring-emerald-100 dark:ring-emerald-800',\n pill: 'bg-emerald-600/90 text-emerald-50',\n progressTrack: 'bg-emerald-200/60 dark:bg-emerald-900/50',\n progressFill: 'bg-emerald-500',\n name: 'text-emerald-900 dark:text-emerald-100',\n },\n blue: {\n card: 'bg-blue-50/80 dark:bg-blue-900/20 ring-1 ring-blue-100 dark:ring-blue-800',\n pill: 'bg-blue-600/90 text-blue-50',\n progressTrack: 'bg-blue-200/60 dark:bg-blue-900/50',\n progressFill: 'bg-blue-600',\n name: 'text-blue-900 dark:text-blue-100',\n },\n zinc: {\n card: 'bg-zinc-100/70 dark:bg-zinc-900/30 ring-1 ring-zinc-200 dark:ring-zinc-800',\n pill: 'bg-zinc-800 text-zinc-50',\n progressTrack: 'bg-zinc-200/60 dark:bg-zinc-800',\n progressFill: 'bg-zinc-600',\n name: 'text-zinc-900 dark:text-zinc-100',\n },\n};\n\nconst sizeMap: Record<\n Size,\n { pad: string; name: string; pill: string; caption: string; avatar: string }\n> = {\n sm: {\n pad: 'p-4',\n name: 'text-base',\n pill: 'text-[11px] px-2.5 py-1',\n caption: 'text-xs',\n avatar: 'h-14 w-14',\n },\n md: {\n pad: 'p-6',\n name: 'text-lg',\n pill: 'text-xs px-3 py-1.5',\n caption: 'text-sm',\n avatar: 'h-16 w-16',\n },\n lg: {\n pad: 'p-8',\n name: 'text-xl',\n pill: 'text-sm px-3.5 py-1.5',\n caption: 'text-sm',\n avatar: 'h-20 w-20',\n },\n};\n\nfunction formatAmount(amount?: number | string, prefix?: string) {\n if (amount == null) return undefined;\n if (typeof amount === 'string') return amount;\n try {\n return `${prefix ?? ''}${amount.toLocaleString()}`;\n } catch {\n return `${prefix ?? ''}${amount}`;\n }\n}\n\nexport function LeaderboardCard({\n name,\n amount,\n amountPrefix = '$',\n avatarSrc,\n avatarAlt = name,\n rank,\n score = 0,\n label = 'Score',\n showCrown = rank === 1,\n tone = 'emerald',\n size = 'md',\n className,\n avatarRingClassName,\n progressClassName,\n pillClassName,\n}: LeaderboardCardProps) {\n const t = toneMap[tone];\n const s = sizeMap[size];\n const pct = Math.max(0, Math.min(100, score));\n const amountLabel = formatAmount(amount, amountPrefix);\n\n return (\n \n {/* avatar */}\n
\n
\n \n {avatarSrc ? (\n // eslint-disable-next-line @next/next/no-img-element\n \n ) : (\n
\n \n {name?.[0] ?? '?'}\n \n
\n )}\n {showCrown && (\n \n )}\n
\n {typeof rank === 'number' && (\n \n {rank}\n \n )}\n
\n\n {/* name + amount pill */}\n
\n {name}\n
\n {amountLabel && (\n \n {amountLabel}\n \n )}\n
\n\n {/* score bar */}\n
\n
\n {label}\n {pct}%\n
\n \n \n
\n
\n
\n );\n}\n", "type": "registry:ui" } ] diff --git a/public/c/registry.json b/public/c/registry.json index 93035ac..3706500 100644 --- a/public/c/registry.json +++ b/public/c/registry.json @@ -15,7 +15,7 @@ "registryDependencies": [], "files": [ { - "path": "components/core/kpi-card.tsx", + "path": "\\components\\core\\kpi-card.tsx", "type": "registry:component" } ], @@ -36,7 +36,7 @@ "registryDependencies": [], "files": [ { - "path": "components/core/leaderboard-card.tsx", + "path": "\\components\\core\\leaderboard-card.tsx", "type": "registry:component" } ], @@ -55,7 +55,7 @@ "registryDependencies": [], "files": [ { - "path": "components/core/radial-dots-spinner.tsx", + "path": "\\components\\core\\radial-dots-spinner.tsx", "type": "registry:component" } ], @@ -74,7 +74,7 @@ "registryDependencies": [], "files": [ { - "path": "components/core/concentric-rings-spinner.tsx", + "path": "\\components\\core\\concentric-rings-spinner.tsx", "type": "registry:component" } ], @@ -95,7 +95,7 @@ "registryDependencies": [], "files": [ { - "path": "components/core/spinner-sequential-pulse.tsx", + "path": "\\components\\core\\spinner-sequential-pulse.tsx", "type": "registry:component" } ], @@ -117,7 +117,7 @@ "registryDependencies": [], "files": [ { - "path": "components/core/testimonial.tsx", + "path": "\\components\\core\\testimonial.tsx", "type": "registry:component" } ], @@ -136,7 +136,7 @@ "registryDependencies": [], "files": [ { - "path": "components/core/blog-card-one.tsx", + "path": "\\components\\core\\blog-card-one.tsx", "type": "registry:component" } ], @@ -157,7 +157,7 @@ "registryDependencies": [], "files": [ { - "path": "components/core/blog-card-two.tsx", + "path": "\\components\\core\\blog-card-two.tsx", "type": "registry:component" } ], @@ -178,7 +178,7 @@ "registryDependencies": [], "files": [ { - "path": "components/core/blog-card-three.tsx", + "path": "\\components\\core\\blog-card-three.tsx", "type": "registry:component" } ], @@ -199,7 +199,7 @@ "registryDependencies": [], "files": [ { - "path": "components/core/blog-card-four.tsx", + "path": "\\components\\core\\blog-card-four.tsx", "type": "registry:component" } ], @@ -218,7 +218,7 @@ "registryDependencies": [], "files": [ { - "path": "components/core/timeline-rail.tsx", + "path": "\\components\\core\\timeline-rail.tsx", "type": "registry:component" } ], @@ -237,7 +237,7 @@ "registryDependencies": [], "files": [ { - "path": "components/core/pricing-card-one.tsx", + "path": "\\components\\core\\pricing-card-one.tsx", "type": "registry:component" } ], @@ -256,7 +256,47 @@ "registryDependencies": [], "files": [ { - "path": "components/core/pricing-card-two.tsx", + "path": "\\components\\core\\pricing-card-two.tsx", + "type": "registry:component" + } + ], + "categories": [ + "ui", + "shadcn-extras" + ] + }, + { + "name": "chevron-steps", + "type": "registry:ui", + "title": "Chevron Steps", + "description": "Arrowed step progress bar with clickable segments.", + "dependencies": [], + "devDependencies": [], + "registryDependencies": [], + "files": [ + { + "path": "\\components\\core\\chevron-steps.tsx", + "type": "registry:component" + } + ], + "categories": [ + "ui", + "shadcn-extras" + ] + }, + { + "name": "text-circle-scroll", + "type": "registry:ui", + "title": "Text Circle Scroll", + "description": "Circular text/nodes that rotate with scroll and/or auto-spin.", + "dependencies": [ + "motion" + ], + "devDependencies": [], + "registryDependencies": [], + "files": [ + { + "path": "\\components\\core\\text-circle-scroll.tsx", "type": "registry:component" } ], diff --git a/public/c/text-circle-scroll.json b/public/c/text-circle-scroll.json new file mode 100644 index 0000000..fd18650 --- /dev/null +++ b/public/c/text-circle-scroll.json @@ -0,0 +1,21 @@ +{ + "name": "text-circle-scroll", + "type": "registry:ui", + "registryDependencies": [], + "dependencies": [ + "motion" + ], + "devDependencies": [], + "tailwind": {}, + "cssVars": { + "light": {}, + "dark": {} + }, + "files": [ + { + "path": "text-circle-scroll.tsx", + "content": "'use client';\n\nimport * as React from 'react';\nimport { motion, useScroll, useTransform, useSpring } from 'motion/react';\nimport { cn } from '@/lib/utils';\n\nexport type TextCircleItem = string | React.ReactNode;\n\nexport type TextCircleScrollProps = {\n /** Items placed on the ring in order (clockwise by default). */\n items: TextCircleItem[];\n\n /** Radius in px (distance from center to baseline of items). */\n radius?: number;\n\n /** Empty hole diameter in px (purely visual helper class on container). */\n innerGap?: number;\n\n /** Angle offset in degrees for the first item. */\n startAngle?: number;\n\n /** Clockwise (true) or counter-clockwise (false) item order. */\n clockwise?: boolean;\n\n /** If true, connects rotation to page scroll progress. */\n rotateOnScroll?: boolean;\n\n /**\n * How many degrees the ring rotates from top->bottom of the page when\n * rotateOnScroll = true. 360 = one full turn.\n */\n scrollDegrees?: number;\n\n /** Constant rotation (deg/s). Can be negative for reverse. */\n autoSpinDegPerSec?: number;\n\n /** Springiness for scroll rotation. Set 0 to disable spring. */\n springStiffness?: number;\n\n /** Container className */\n className?: string;\n\n /** Ring element className */\n ringClassName?: string;\n\n /** Per-item className */\n itemClassName?: string;\n\n /** Typography class for items (e.g. 'text-lg font-serif') */\n textClassName?: string;\n\n /** If provided, constrain the component height (useful in docs/examples). */\n height?: number | string;\n};\n\n/**\n * TextCircleScroll\n * - Places any text/nodes around a circle\n * - Rotates with scroll (and/or auto-spin)\n * - Purely presentational; does not manage layout outside itself\n */\nexport default function TextCircleScroll({\n items,\n radius = 110,\n innerGap = 90,\n startAngle = -90,\n clockwise = true,\n rotateOnScroll = true,\n scrollDegrees = 360,\n autoSpinDegPerSec = 0,\n springStiffness = 120,\n className,\n ringClassName,\n itemClassName,\n textClassName = 'text-base font-serif text-zinc-800 dark:text-zinc-200',\n height = 320,\n}: TextCircleScrollProps) {\n const ref = React.useRef(null);\n\n // --- Scroll-driven rotation\n const { scrollYProgress } = useScroll({\n // when the component is within the viewport\n target: ref,\n offset: ['start end', 'end start'],\n });\n\n const rawRotate = useTransform(scrollYProgress, [0, 1], [0, scrollDegrees]);\n const rotateSpring = useSpring(rawRotate, {\n stiffness: springStiffness || 120,\n damping: 18,\n mass: 0.6,\n });\n\n // --- Auto-spin via requestAnimationFrame\n const [autoAngle, setAutoAngle] = React.useState(0);\n React.useEffect(() => {\n if (!autoSpinDegPerSec) return;\n let raf = 0;\n let last = performance.now();\n const tick = (t: number) => {\n const dt = (t - last) / 1000;\n last = t;\n setAutoAngle((a) => a + autoSpinDegPerSec * dt);\n raf = requestAnimationFrame(tick);\n };\n raf = requestAnimationFrame(tick);\n return () => cancelAnimationFrame(raf);\n }, [autoSpinDegPerSec]);\n\n // Combine scroll rotation + auto rotation.\n const [combined, setCombined] = React.useState(0);\n React.useEffect(() => {\n const unsub = rotateSpring.on('change', (v) => setCombined(v));\n return () => unsub();\n }, [rotateSpring]);\n\n const totalRotation = (rotateOnScroll ? combined : 0) + autoAngle;\n\n const n = items.length;\n const step = 360 / Math.max(1, n);\n const dir = clockwise ? 1 : -1;\n\n return (\n \n {/* Visual inner hole helper */}\n \n {/* Ring */}\n \n {items.map((item, i) => {\n const angle = startAngle + dir * i * step;\n // Place at angle: rotate container then translate then un-rotate\n const style: React.CSSProperties = {\n position: 'absolute',\n top: '50%',\n left: '50%',\n transform: `rotate(${angle}deg) translate(${radius}px) rotate(${clockwise ? 90 : -90}deg)`,\n transformOrigin: '0 0',\n whiteSpace: 'nowrap',\n };\n return (\n \n \n {typeof item === 'string' ? item : item}\n \n \n );\n })}\n \n \n );\n}\n", + "type": "registry:ui" + } + ] +} \ No newline at end of file diff --git a/public/e/chevron-steps-basic.json b/public/e/chevron-steps-basic.json new file mode 100644 index 0000000..d07b05e --- /dev/null +++ b/public/e/chevron-steps-basic.json @@ -0,0 +1,18 @@ +{ + "name": "chevron-steps-basic", + "type": "registry:ui", + "componentName": "chevron-steps-basic", + "description": "Brand variant like the reference screenshot.", + "files": [ + { + "path": "chevron-steps-basic.tsx", + "content": "import ChevronSteps from '@/components/core/chevron-steps';\n\nexport function ChevronStepsBasic() {\n return (\n
\n \n
\n );\n}\n", + "type": "registry:component" + }, + { + "path": "components/core/chevron-steps.tsx", + "content": "'use client';\n\nimport * as React from 'react';\nimport { cn } from '@/lib/utils';\n\nexport type ChevronStep = {\n id?: string | number;\n label: string;\n /** Optional accessible description (shown via title attr). */\n description?: string;\n /** Optional custom classes per step. */\n className?: string;\n /** Disable click for this step. */\n disabled?: boolean;\n};\n\nexport type ChevronStepsProps = {\n /** Steps in order. */\n steps: ChevronStep[];\n\n /** Zero-based current step index. */\n current?: number;\n\n /** Called when a (non-disabled) step is clicked. */\n onStepClick?: (index: number, step: ChevronStep) => void;\n\n /** Sizes & look */\n size?: 'sm' | 'md' | 'lg';\n variant?: 'brand' | 'neutral';\n\n /** Tail (arrow) width in px (CSS variable) */\n tailWidth?: number;\n\n /** Roundness on the bar */\n radius?: 'md' | 'lg' | 'xl' | '2xl';\n\n /** Allow horizontal scroll on small screens */\n scrollable?: boolean;\n\n /** Root class */\n className?: string;\n\n /** Slot overrides */\n stepClassName?: string;\n stepActiveClassName?: string;\n stepCompletedClassName?: string;\n stepUpcomingClassName?: string;\n};\n\n/**\n * ChevronSteps: horizontally joined chevron items with a configurable arrow tail,\n * a11y-friendly (list+buttons), and simple API.\n */\nexport default function ChevronSteps({\n steps,\n current = 0,\n onStepClick,\n size = 'md',\n variant = 'brand',\n tailWidth = 18,\n radius = '2xl',\n scrollable = true,\n className,\n stepClassName,\n stepActiveClassName,\n stepCompletedClassName,\n stepUpcomingClassName,\n}: ChevronStepsProps) {\n const h =\n size === 'sm'\n ? 'h-8 text-xs'\n : size === 'lg'\n ? 'h-14 text-base'\n : 'h-11 text-sm';\n const pad = size === 'sm' ? 'px-4' : size === 'lg' ? 'px-7' : 'px-6';\n const r =\n radius === 'md'\n ? 'rounded-md'\n : radius === 'lg'\n ? 'rounded-lg'\n : radius === 'xl'\n ? 'rounded-xl'\n : 'rounded-2xl';\n\n // The chevron shape uses a clip-path polygon with --twc-tail custom property\n // to create the arrow head. We mask the first/last to give rounded ends.\n const baseStep =\n 'relative isolate flex grow select-none items-center justify-center whitespace-nowrap font-medium transition-colors';\n\n const theme =\n variant === 'brand'\n ? {\n active: 'bg-blue-700 text-white',\n completed:\n 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-200',\n upcoming:\n 'bg-zinc-100 text-zinc-600 dark:bg-zinc-900 dark:text-zinc-400',\n border: 'ring-1 ring-white/70 dark:ring-black/20',\n }\n : {\n active: 'bg-zinc-800 text-white dark:bg-zinc-200 dark:text-zinc-900',\n completed:\n 'bg-zinc-200 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200',\n upcoming:\n 'bg-zinc-100 text-zinc-600 dark:bg-zinc-900 dark:text-zinc-400',\n border: 'ring-1 ring-white/70 dark:ring-black/20',\n };\n\n const containerClasses = cn(\n 'relative',\n r,\n scrollable ? 'overflow-x-auto' : 'overflow-hidden',\n 'bg-transparent',\n className\n );\n\n return (\n
\n \n {steps.map((step, i) => {\n const state =\n i < current ? 'completed' : i === current ? 'active' : 'upcoming';\n\n const color =\n state === 'active'\n ? theme.active\n : state === 'completed'\n ? theme.completed\n : theme.upcoming;\n\n const override =\n state === 'active'\n ? stepActiveClassName\n : state === 'completed'\n ? stepCompletedClassName\n : stepUpcomingClassName;\n\n // leftmost and rightmost need rounded masks\n const roundLeft = i === 0 ? r : '';\n const roundRight = i === steps.length - 1 ? r : '';\n\n // Each step uses clip-path polygon to form a chevron.\n // We add a tiny overlap (-0.5px) to avoid hairline gaps between shapes.\n return (\n
  • \n {\n if (!step.disabled && onStepClick) onStepClick(i, step);\n }}\n >\n {step.label}\n \n
  • \n );\n })}\n \n
    \n );\n}\n", + "type": "registry:ui" + } + ] +} \ No newline at end of file diff --git a/public/e/chevron-steps-neutral.json b/public/e/chevron-steps-neutral.json new file mode 100644 index 0000000..2250b39 --- /dev/null +++ b/public/e/chevron-steps-neutral.json @@ -0,0 +1,18 @@ +{ + "name": "chevron-steps-neutral", + "type": "registry:ui", + "componentName": "chevron-steps-neutral", + "description": "Neutral, large variant.", + "files": [ + { + "path": "chevron-steps-neutral.tsx", + "content": "import ChevronSteps from '@/components/core/chevron-steps';\n\nexport function ChevronStepsNeutral() {\n return (\n
    \n \n
    \n );\n}\n", + "type": "registry:component" + }, + { + "path": "components/core/chevron-steps.tsx", + "content": "'use client';\n\nimport * as React from 'react';\nimport { cn } from '@/lib/utils';\n\nexport type ChevronStep = {\n id?: string | number;\n label: string;\n /** Optional accessible description (shown via title attr). */\n description?: string;\n /** Optional custom classes per step. */\n className?: string;\n /** Disable click for this step. */\n disabled?: boolean;\n};\n\nexport type ChevronStepsProps = {\n /** Steps in order. */\n steps: ChevronStep[];\n\n /** Zero-based current step index. */\n current?: number;\n\n /** Called when a (non-disabled) step is clicked. */\n onStepClick?: (index: number, step: ChevronStep) => void;\n\n /** Sizes & look */\n size?: 'sm' | 'md' | 'lg';\n variant?: 'brand' | 'neutral';\n\n /** Tail (arrow) width in px (CSS variable) */\n tailWidth?: number;\n\n /** Roundness on the bar */\n radius?: 'md' | 'lg' | 'xl' | '2xl';\n\n /** Allow horizontal scroll on small screens */\n scrollable?: boolean;\n\n /** Root class */\n className?: string;\n\n /** Slot overrides */\n stepClassName?: string;\n stepActiveClassName?: string;\n stepCompletedClassName?: string;\n stepUpcomingClassName?: string;\n};\n\n/**\n * ChevronSteps: horizontally joined chevron items with a configurable arrow tail,\n * a11y-friendly (list+buttons), and simple API.\n */\nexport default function ChevronSteps({\n steps,\n current = 0,\n onStepClick,\n size = 'md',\n variant = 'brand',\n tailWidth = 18,\n radius = '2xl',\n scrollable = true,\n className,\n stepClassName,\n stepActiveClassName,\n stepCompletedClassName,\n stepUpcomingClassName,\n}: ChevronStepsProps) {\n const h =\n size === 'sm'\n ? 'h-8 text-xs'\n : size === 'lg'\n ? 'h-14 text-base'\n : 'h-11 text-sm';\n const pad = size === 'sm' ? 'px-4' : size === 'lg' ? 'px-7' : 'px-6';\n const r =\n radius === 'md'\n ? 'rounded-md'\n : radius === 'lg'\n ? 'rounded-lg'\n : radius === 'xl'\n ? 'rounded-xl'\n : 'rounded-2xl';\n\n // The chevron shape uses a clip-path polygon with --twc-tail custom property\n // to create the arrow head. We mask the first/last to give rounded ends.\n const baseStep =\n 'relative isolate flex grow select-none items-center justify-center whitespace-nowrap font-medium transition-colors';\n\n const theme =\n variant === 'brand'\n ? {\n active: 'bg-blue-700 text-white',\n completed:\n 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-200',\n upcoming:\n 'bg-zinc-100 text-zinc-600 dark:bg-zinc-900 dark:text-zinc-400',\n border: 'ring-1 ring-white/70 dark:ring-black/20',\n }\n : {\n active: 'bg-zinc-800 text-white dark:bg-zinc-200 dark:text-zinc-900',\n completed:\n 'bg-zinc-200 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200',\n upcoming:\n 'bg-zinc-100 text-zinc-600 dark:bg-zinc-900 dark:text-zinc-400',\n border: 'ring-1 ring-white/70 dark:ring-black/20',\n };\n\n const containerClasses = cn(\n 'relative',\n r,\n scrollable ? 'overflow-x-auto' : 'overflow-hidden',\n 'bg-transparent',\n className\n );\n\n return (\n
    \n \n {steps.map((step, i) => {\n const state =\n i < current ? 'completed' : i === current ? 'active' : 'upcoming';\n\n const color =\n state === 'active'\n ? theme.active\n : state === 'completed'\n ? theme.completed\n : theme.upcoming;\n\n const override =\n state === 'active'\n ? stepActiveClassName\n : state === 'completed'\n ? stepCompletedClassName\n : stepUpcomingClassName;\n\n // leftmost and rightmost need rounded masks\n const roundLeft = i === 0 ? r : '';\n const roundRight = i === steps.length - 1 ? r : '';\n\n // Each step uses clip-path polygon to form a chevron.\n // We add a tiny overlap (-0.5px) to avoid hairline gaps between shapes.\n return (\n
  • \n {\n if (!step.disabled && onStepClick) onStepClick(i, step);\n }}\n >\n {step.label}\n \n
  • \n );\n })}\n \n
    \n );\n}\n", + "type": "registry:ui" + } + ] +} \ No newline at end of file diff --git a/public/e/chevron-steps-progress.json b/public/e/chevron-steps-progress.json new file mode 100644 index 0000000..d74c79b --- /dev/null +++ b/public/e/chevron-steps-progress.json @@ -0,0 +1,18 @@ +{ + "name": "chevron-steps-progress", + "type": "registry:ui", + "componentName": "chevron-steps-progress", + "description": "Interactive progress with Prev/Next.", + "files": [ + { + "path": "chevron-steps-progress.tsx", + "content": "'use client';\nimport * as React from 'react';\nimport ChevronSteps from '@/components/core/chevron-steps';\n\nexport function ChevronStepsProgress() {\n const [idx, setIdx] = React.useState(2);\n return (\n
    \n \n
    \n setIdx((v) => Math.max(0, v - 1))}\n >\n Prev\n \n setIdx((v) => Math.min(3, v + 1))}\n >\n Next\n \n
    \n
    \n );\n}\n", + "type": "registry:component" + }, + { + "path": "components/core/chevron-steps.tsx", + "content": "'use client';\n\nimport * as React from 'react';\nimport { cn } from '@/lib/utils';\n\nexport type ChevronStep = {\n id?: string | number;\n label: string;\n /** Optional accessible description (shown via title attr). */\n description?: string;\n /** Optional custom classes per step. */\n className?: string;\n /** Disable click for this step. */\n disabled?: boolean;\n};\n\nexport type ChevronStepsProps = {\n /** Steps in order. */\n steps: ChevronStep[];\n\n /** Zero-based current step index. */\n current?: number;\n\n /** Called when a (non-disabled) step is clicked. */\n onStepClick?: (index: number, step: ChevronStep) => void;\n\n /** Sizes & look */\n size?: 'sm' | 'md' | 'lg';\n variant?: 'brand' | 'neutral';\n\n /** Tail (arrow) width in px (CSS variable) */\n tailWidth?: number;\n\n /** Roundness on the bar */\n radius?: 'md' | 'lg' | 'xl' | '2xl';\n\n /** Allow horizontal scroll on small screens */\n scrollable?: boolean;\n\n /** Root class */\n className?: string;\n\n /** Slot overrides */\n stepClassName?: string;\n stepActiveClassName?: string;\n stepCompletedClassName?: string;\n stepUpcomingClassName?: string;\n};\n\n/**\n * ChevronSteps: horizontally joined chevron items with a configurable arrow tail,\n * a11y-friendly (list+buttons), and simple API.\n */\nexport default function ChevronSteps({\n steps,\n current = 0,\n onStepClick,\n size = 'md',\n variant = 'brand',\n tailWidth = 18,\n radius = '2xl',\n scrollable = true,\n className,\n stepClassName,\n stepActiveClassName,\n stepCompletedClassName,\n stepUpcomingClassName,\n}: ChevronStepsProps) {\n const h =\n size === 'sm'\n ? 'h-8 text-xs'\n : size === 'lg'\n ? 'h-14 text-base'\n : 'h-11 text-sm';\n const pad = size === 'sm' ? 'px-4' : size === 'lg' ? 'px-7' : 'px-6';\n const r =\n radius === 'md'\n ? 'rounded-md'\n : radius === 'lg'\n ? 'rounded-lg'\n : radius === 'xl'\n ? 'rounded-xl'\n : 'rounded-2xl';\n\n // The chevron shape uses a clip-path polygon with --twc-tail custom property\n // to create the arrow head. We mask the first/last to give rounded ends.\n const baseStep =\n 'relative isolate flex grow select-none items-center justify-center whitespace-nowrap font-medium transition-colors';\n\n const theme =\n variant === 'brand'\n ? {\n active: 'bg-blue-700 text-white',\n completed:\n 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-200',\n upcoming:\n 'bg-zinc-100 text-zinc-600 dark:bg-zinc-900 dark:text-zinc-400',\n border: 'ring-1 ring-white/70 dark:ring-black/20',\n }\n : {\n active: 'bg-zinc-800 text-white dark:bg-zinc-200 dark:text-zinc-900',\n completed:\n 'bg-zinc-200 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200',\n upcoming:\n 'bg-zinc-100 text-zinc-600 dark:bg-zinc-900 dark:text-zinc-400',\n border: 'ring-1 ring-white/70 dark:ring-black/20',\n };\n\n const containerClasses = cn(\n 'relative',\n r,\n scrollable ? 'overflow-x-auto' : 'overflow-hidden',\n 'bg-transparent',\n className\n );\n\n return (\n
    \n \n {steps.map((step, i) => {\n const state =\n i < current ? 'completed' : i === current ? 'active' : 'upcoming';\n\n const color =\n state === 'active'\n ? theme.active\n : state === 'completed'\n ? theme.completed\n : theme.upcoming;\n\n const override =\n state === 'active'\n ? stepActiveClassName\n : state === 'completed'\n ? stepCompletedClassName\n : stepUpcomingClassName;\n\n // leftmost and rightmost need rounded masks\n const roundLeft = i === 0 ? r : '';\n const roundRight = i === steps.length - 1 ? r : '';\n\n // Each step uses clip-path polygon to form a chevron.\n // We add a tiny overlap (-0.5px) to avoid hairline gaps between shapes.\n return (\n
  • \n {\n if (!step.disabled && onStepClick) onStepClick(i, step);\n }}\n >\n {step.label}\n \n
  • \n );\n })}\n \n
    \n );\n}\n", + "type": "registry:ui" + } + ] +} \ No newline at end of file diff --git a/public/e/leaderboard-first.json b/public/e/leaderboard-first.json index de3e8cb..3528da9 100644 --- a/public/e/leaderboard-first.json +++ b/public/e/leaderboard-first.json @@ -11,7 +11,7 @@ }, { "path": "components/core/leaderboard-card.tsx", - "content": "'use client';\n\nimport * as React from 'react';\nimport { cn } from '@/lib/utils';\nimport { Crown } from 'lucide-react';\n\ntype Tone = 'emerald' | 'blue' | 'zinc';\ntype Size = 'sm' | 'md' | 'lg';\n\nexport type LeaderboardCardProps = {\n name: string;\n amount?: number | string; // e.g. 8034 or \"$8,034\"\n amountPrefix?: string; // e.g. \"$\"\n avatarSrc?: string;\n avatarAlt?: string;\n rank?: number; // 1..n (shows small badge)\n score?: number; // 0..100 -> progress + right-aligned %\n label?: string; // left label under pill (default \"Score\")\n showCrown?: boolean; // show crown for #1\n tone?: Tone; // background + bar color\n size?: Size; // paddings/fonts\n className?: string;\n /** Optional custom avatar ring class (e.g., gradient ring) */\n avatarRingClassName?: string;\n /** Optional custom progress color class (overrides tone) */\n progressClassName?: string;\n /** Optional custom pill class */\n pillClassName?: string;\n};\n\nconst toneMap: Record<\n Tone,\n {\n card: string;\n pill: string;\n progressTrack: string;\n progressFill: string;\n name: string;\n }\n> = {\n emerald: {\n card: 'bg-emerald-50/70 dark:bg-emerald-900/20 ring-1 ring-emerald-100 dark:ring-emerald-800',\n pill: 'bg-emerald-600/90 text-emerald-50',\n progressTrack: 'bg-emerald-200/60 dark:bg-emerald-900/50',\n progressFill: 'bg-emerald-500',\n name: 'text-emerald-900 dark:text-emerald-100',\n },\n blue: {\n card: 'bg-blue-50/80 dark:bg-blue-900/20 ring-1 ring-blue-100 dark:ring-blue-800',\n pill: 'bg-blue-600/90 text-blue-50',\n progressTrack: 'bg-blue-200/60 dark:bg-blue-900/50',\n progressFill: 'bg-blue-600',\n name: 'text-blue-900 dark:text-blue-100',\n },\n zinc: {\n card: 'bg-zinc-100/70 dark:bg-zinc-900/30 ring-1 ring-zinc-200 dark:ring-zinc-800',\n pill: 'bg-zinc-800 text-zinc-50',\n progressTrack: 'bg-zinc-200/60 dark:bg-zinc-800',\n progressFill: 'bg-zinc-600',\n name: 'text-zinc-900 dark:text-zinc-100',\n },\n};\n\nconst sizeMap: Record<\n Size,\n { pad: string; name: string; pill: string; caption: string; avatar: string }\n> = {\n sm: {\n pad: 'p-4',\n name: 'text-base',\n pill: 'text-[11px] px-2.5 py-1',\n caption: 'text-xs',\n avatar: 'h-14 w-14',\n },\n md: {\n pad: 'p-6',\n name: 'text-lg',\n pill: 'text-xs px-3 py-1.5',\n caption: 'text-sm',\n avatar: 'h-16 w-16',\n },\n lg: {\n pad: 'p-8',\n name: 'text-xl',\n pill: 'text-sm px-3.5 py-1.5',\n caption: 'text-sm',\n avatar: 'h-20 w-20',\n },\n};\n\nfunction formatAmount(amount?: number | string, prefix?: string) {\n if (amount == null) return undefined;\n if (typeof amount === 'string') return amount;\n try {\n return `${prefix ?? ''}${amount.toLocaleString()}`;\n } catch {\n return `${prefix ?? ''}${amount}`;\n }\n}\n\nexport function LeaderboardCard({\n name,\n amount,\n amountPrefix = '$',\n avatarSrc,\n avatarAlt = name,\n rank,\n score = 0,\n label = 'Score',\n showCrown = rank === 1,\n tone = 'emerald',\n size = 'md',\n className,\n avatarRingClassName,\n progressClassName,\n pillClassName,\n}: LeaderboardCardProps) {\n const t = toneMap[tone];\n const s = sizeMap[size];\n const pct = Math.max(0, Math.min(100, score));\n const amountLabel = formatAmount(amount, amountPrefix);\n\n return (\n \n {/* avatar */}\n
    \n
    \n \n {avatarSrc ? (\n // eslint-disable-next-line @next/next/no-img-element\n \n ) : (\n
    \n \n {name?.[0] ?? '?'}\n \n
    \n )}\n {showCrown && (\n \n )}\n
    \n {typeof rank === 'number' && (\n \n {rank}\n \n )}\n
    \n\n {/* name + amount pill */}\n
    \n {name}\n
    \n {amountLabel && (\n \n {amountLabel}\n \n )}\n \n\n {/* score bar */}\n
    \n
    \n {label}\n {pct}%\n
    \n \n \n
    \n \n \n );\n}\n", + "content": "'use client';\n\nimport * as React from 'react';\nimport { cn } from '@/lib/utils';\nimport { Crown } from 'lucide-react';\n\ntype Tone = 'emerald' | 'blue' | 'zinc';\ntype Size = 'sm' | 'md' | 'lg';\n\nexport type LeaderboardCardProps = {\n name: string;\n amount?: number | string; // e.g. 8034 or \"$8,034\"\n amountPrefix?: string; // e.g. \"$\"\n avatarSrc?: string;\n avatarAlt?: string;\n rank?: number; // 1..n (shows small badge)\n score?: number; // 0..100 -> progress + right-aligned %\n label?: string; // left label under pill (default \"Score\")\n showCrown?: boolean; // show crown for #1\n tone?: Tone; // background + bar color\n size?: Size; // paddings/fonts\n className?: string;\n /** Optional custom avatar ring class (e.g., gradient ring) */\n avatarRingClassName?: string;\n /** Optional custom progress color class (overrides tone) */\n progressClassName?: string;\n /** Optional custom pill class */\n pillClassName?: string;\n};\n\nconst toneMap: Record<\n Tone,\n {\n card: string;\n pill: string;\n progressTrack: string;\n progressFill: string;\n name: string;\n }\n> = {\n emerald: {\n card: 'bg-emerald-50/70 dark:bg-emerald-900/20 ring-1 ring-emerald-100 dark:ring-emerald-800',\n pill: 'bg-emerald-600/90 text-emerald-50',\n progressTrack: 'bg-emerald-200/60 dark:bg-emerald-900/50',\n progressFill: 'bg-emerald-500',\n name: 'text-emerald-900 dark:text-emerald-100',\n },\n blue: {\n card: 'bg-blue-50/80 dark:bg-blue-900/20 ring-1 ring-blue-100 dark:ring-blue-800',\n pill: 'bg-blue-600/90 text-blue-50',\n progressTrack: 'bg-blue-200/60 dark:bg-blue-900/50',\n progressFill: 'bg-blue-600',\n name: 'text-blue-900 dark:text-blue-100',\n },\n zinc: {\n card: 'bg-zinc-100/70 dark:bg-zinc-900/30 ring-1 ring-zinc-200 dark:ring-zinc-800',\n pill: 'bg-zinc-800 text-zinc-50',\n progressTrack: 'bg-zinc-200/60 dark:bg-zinc-800',\n progressFill: 'bg-zinc-600',\n name: 'text-zinc-900 dark:text-zinc-100',\n },\n};\n\nconst sizeMap: Record<\n Size,\n { pad: string; name: string; pill: string; caption: string; avatar: string }\n> = {\n sm: {\n pad: 'p-4',\n name: 'text-base',\n pill: 'text-[11px] px-2.5 py-1',\n caption: 'text-xs',\n avatar: 'h-14 w-14',\n },\n md: {\n pad: 'p-6',\n name: 'text-lg',\n pill: 'text-xs px-3 py-1.5',\n caption: 'text-sm',\n avatar: 'h-16 w-16',\n },\n lg: {\n pad: 'p-8',\n name: 'text-xl',\n pill: 'text-sm px-3.5 py-1.5',\n caption: 'text-sm',\n avatar: 'h-20 w-20',\n },\n};\n\nfunction formatAmount(amount?: number | string, prefix?: string) {\n if (amount == null) return undefined;\n if (typeof amount === 'string') return amount;\n try {\n return `${prefix ?? ''}${amount.toLocaleString()}`;\n } catch {\n return `${prefix ?? ''}${amount}`;\n }\n}\n\nexport function LeaderboardCard({\n name,\n amount,\n amountPrefix = '$',\n avatarSrc,\n avatarAlt = name,\n rank,\n score = 0,\n label = 'Score',\n showCrown = rank === 1,\n tone = 'emerald',\n size = 'md',\n className,\n avatarRingClassName,\n progressClassName,\n pillClassName,\n}: LeaderboardCardProps) {\n const t = toneMap[tone];\n const s = sizeMap[size];\n const pct = Math.max(0, Math.min(100, score));\n const amountLabel = formatAmount(amount, amountPrefix);\n\n return (\n \n {/* avatar */}\n
    \n
    \n \n {avatarSrc ? (\n // eslint-disable-next-line @next/next/no-img-element\n \n ) : (\n
    \n \n {name?.[0] ?? '?'}\n \n
    \n )}\n {showCrown && (\n \n )}\n
    \n {typeof rank === 'number' && (\n \n {rank}\n \n )}\n
    \n\n {/* name + amount pill */}\n
    \n {name}\n
    \n {amountLabel && (\n \n {amountLabel}\n \n )}\n \n\n {/* score bar */}\n
    \n
    \n {label}\n {pct}%\n
    \n \n \n
    \n \n \n );\n}\n", "type": "registry:ui" } ] diff --git a/public/e/leaderboard-second.json b/public/e/leaderboard-second.json index 0083604..cac4ec7 100644 --- a/public/e/leaderboard-second.json +++ b/public/e/leaderboard-second.json @@ -11,7 +11,7 @@ }, { "path": "components/core/leaderboard-card.tsx", - "content": "'use client';\n\nimport * as React from 'react';\nimport { cn } from '@/lib/utils';\nimport { Crown } from 'lucide-react';\n\ntype Tone = 'emerald' | 'blue' | 'zinc';\ntype Size = 'sm' | 'md' | 'lg';\n\nexport type LeaderboardCardProps = {\n name: string;\n amount?: number | string; // e.g. 8034 or \"$8,034\"\n amountPrefix?: string; // e.g. \"$\"\n avatarSrc?: string;\n avatarAlt?: string;\n rank?: number; // 1..n (shows small badge)\n score?: number; // 0..100 -> progress + right-aligned %\n label?: string; // left label under pill (default \"Score\")\n showCrown?: boolean; // show crown for #1\n tone?: Tone; // background + bar color\n size?: Size; // paddings/fonts\n className?: string;\n /** Optional custom avatar ring class (e.g., gradient ring) */\n avatarRingClassName?: string;\n /** Optional custom progress color class (overrides tone) */\n progressClassName?: string;\n /** Optional custom pill class */\n pillClassName?: string;\n};\n\nconst toneMap: Record<\n Tone,\n {\n card: string;\n pill: string;\n progressTrack: string;\n progressFill: string;\n name: string;\n }\n> = {\n emerald: {\n card: 'bg-emerald-50/70 dark:bg-emerald-900/20 ring-1 ring-emerald-100 dark:ring-emerald-800',\n pill: 'bg-emerald-600/90 text-emerald-50',\n progressTrack: 'bg-emerald-200/60 dark:bg-emerald-900/50',\n progressFill: 'bg-emerald-500',\n name: 'text-emerald-900 dark:text-emerald-100',\n },\n blue: {\n card: 'bg-blue-50/80 dark:bg-blue-900/20 ring-1 ring-blue-100 dark:ring-blue-800',\n pill: 'bg-blue-600/90 text-blue-50',\n progressTrack: 'bg-blue-200/60 dark:bg-blue-900/50',\n progressFill: 'bg-blue-600',\n name: 'text-blue-900 dark:text-blue-100',\n },\n zinc: {\n card: 'bg-zinc-100/70 dark:bg-zinc-900/30 ring-1 ring-zinc-200 dark:ring-zinc-800',\n pill: 'bg-zinc-800 text-zinc-50',\n progressTrack: 'bg-zinc-200/60 dark:bg-zinc-800',\n progressFill: 'bg-zinc-600',\n name: 'text-zinc-900 dark:text-zinc-100',\n },\n};\n\nconst sizeMap: Record<\n Size,\n { pad: string; name: string; pill: string; caption: string; avatar: string }\n> = {\n sm: {\n pad: 'p-4',\n name: 'text-base',\n pill: 'text-[11px] px-2.5 py-1',\n caption: 'text-xs',\n avatar: 'h-14 w-14',\n },\n md: {\n pad: 'p-6',\n name: 'text-lg',\n pill: 'text-xs px-3 py-1.5',\n caption: 'text-sm',\n avatar: 'h-16 w-16',\n },\n lg: {\n pad: 'p-8',\n name: 'text-xl',\n pill: 'text-sm px-3.5 py-1.5',\n caption: 'text-sm',\n avatar: 'h-20 w-20',\n },\n};\n\nfunction formatAmount(amount?: number | string, prefix?: string) {\n if (amount == null) return undefined;\n if (typeof amount === 'string') return amount;\n try {\n return `${prefix ?? ''}${amount.toLocaleString()}`;\n } catch {\n return `${prefix ?? ''}${amount}`;\n }\n}\n\nexport function LeaderboardCard({\n name,\n amount,\n amountPrefix = '$',\n avatarSrc,\n avatarAlt = name,\n rank,\n score = 0,\n label = 'Score',\n showCrown = rank === 1,\n tone = 'emerald',\n size = 'md',\n className,\n avatarRingClassName,\n progressClassName,\n pillClassName,\n}: LeaderboardCardProps) {\n const t = toneMap[tone];\n const s = sizeMap[size];\n const pct = Math.max(0, Math.min(100, score));\n const amountLabel = formatAmount(amount, amountPrefix);\n\n return (\n \n {/* avatar */}\n
    \n
    \n \n {avatarSrc ? (\n // eslint-disable-next-line @next/next/no-img-element\n \n ) : (\n
    \n \n {name?.[0] ?? '?'}\n \n
    \n )}\n {showCrown && (\n \n )}\n
    \n {typeof rank === 'number' && (\n \n {rank}\n \n )}\n
    \n\n {/* name + amount pill */}\n
    \n {name}\n
    \n {amountLabel && (\n \n {amountLabel}\n \n )}\n \n\n {/* score bar */}\n
    \n
    \n {label}\n {pct}%\n
    \n \n \n
    \n \n \n );\n}\n", + "content": "'use client';\n\nimport * as React from 'react';\nimport { cn } from '@/lib/utils';\nimport { Crown } from 'lucide-react';\n\ntype Tone = 'emerald' | 'blue' | 'zinc';\ntype Size = 'sm' | 'md' | 'lg';\n\nexport type LeaderboardCardProps = {\n name: string;\n amount?: number | string; // e.g. 8034 or \"$8,034\"\n amountPrefix?: string; // e.g. \"$\"\n avatarSrc?: string;\n avatarAlt?: string;\n rank?: number; // 1..n (shows small badge)\n score?: number; // 0..100 -> progress + right-aligned %\n label?: string; // left label under pill (default \"Score\")\n showCrown?: boolean; // show crown for #1\n tone?: Tone; // background + bar color\n size?: Size; // paddings/fonts\n className?: string;\n /** Optional custom avatar ring class (e.g., gradient ring) */\n avatarRingClassName?: string;\n /** Optional custom progress color class (overrides tone) */\n progressClassName?: string;\n /** Optional custom pill class */\n pillClassName?: string;\n};\n\nconst toneMap: Record<\n Tone,\n {\n card: string;\n pill: string;\n progressTrack: string;\n progressFill: string;\n name: string;\n }\n> = {\n emerald: {\n card: 'bg-emerald-50/70 dark:bg-emerald-900/20 ring-1 ring-emerald-100 dark:ring-emerald-800',\n pill: 'bg-emerald-600/90 text-emerald-50',\n progressTrack: 'bg-emerald-200/60 dark:bg-emerald-900/50',\n progressFill: 'bg-emerald-500',\n name: 'text-emerald-900 dark:text-emerald-100',\n },\n blue: {\n card: 'bg-blue-50/80 dark:bg-blue-900/20 ring-1 ring-blue-100 dark:ring-blue-800',\n pill: 'bg-blue-600/90 text-blue-50',\n progressTrack: 'bg-blue-200/60 dark:bg-blue-900/50',\n progressFill: 'bg-blue-600',\n name: 'text-blue-900 dark:text-blue-100',\n },\n zinc: {\n card: 'bg-zinc-100/70 dark:bg-zinc-900/30 ring-1 ring-zinc-200 dark:ring-zinc-800',\n pill: 'bg-zinc-800 text-zinc-50',\n progressTrack: 'bg-zinc-200/60 dark:bg-zinc-800',\n progressFill: 'bg-zinc-600',\n name: 'text-zinc-900 dark:text-zinc-100',\n },\n};\n\nconst sizeMap: Record<\n Size,\n { pad: string; name: string; pill: string; caption: string; avatar: string }\n> = {\n sm: {\n pad: 'p-4',\n name: 'text-base',\n pill: 'text-[11px] px-2.5 py-1',\n caption: 'text-xs',\n avatar: 'h-14 w-14',\n },\n md: {\n pad: 'p-6',\n name: 'text-lg',\n pill: 'text-xs px-3 py-1.5',\n caption: 'text-sm',\n avatar: 'h-16 w-16',\n },\n lg: {\n pad: 'p-8',\n name: 'text-xl',\n pill: 'text-sm px-3.5 py-1.5',\n caption: 'text-sm',\n avatar: 'h-20 w-20',\n },\n};\n\nfunction formatAmount(amount?: number | string, prefix?: string) {\n if (amount == null) return undefined;\n if (typeof amount === 'string') return amount;\n try {\n return `${prefix ?? ''}${amount.toLocaleString()}`;\n } catch {\n return `${prefix ?? ''}${amount}`;\n }\n}\n\nexport function LeaderboardCard({\n name,\n amount,\n amountPrefix = '$',\n avatarSrc,\n avatarAlt = name,\n rank,\n score = 0,\n label = 'Score',\n showCrown = rank === 1,\n tone = 'emerald',\n size = 'md',\n className,\n avatarRingClassName,\n progressClassName,\n pillClassName,\n}: LeaderboardCardProps) {\n const t = toneMap[tone];\n const s = sizeMap[size];\n const pct = Math.max(0, Math.min(100, score));\n const amountLabel = formatAmount(amount, amountPrefix);\n\n return (\n \n {/* avatar */}\n
    \n
    \n \n {avatarSrc ? (\n // eslint-disable-next-line @next/next/no-img-element\n \n ) : (\n
    \n \n {name?.[0] ?? '?'}\n \n
    \n )}\n {showCrown && (\n \n )}\n
    \n {typeof rank === 'number' && (\n \n {rank}\n \n )}\n
    \n\n {/* name + amount pill */}\n
    \n {name}\n
    \n {amountLabel && (\n \n {amountLabel}\n \n )}\n \n\n {/* score bar */}\n
    \n
    \n {label}\n {pct}%\n
    \n \n \n
    \n \n \n );\n}\n", "type": "registry:ui" } ] diff --git a/public/e/text-circle-scroll-auto.json b/public/e/text-circle-scroll-auto.json new file mode 100644 index 0000000..80ed309 --- /dev/null +++ b/public/e/text-circle-scroll-auto.json @@ -0,0 +1,18 @@ +{ + "name": "text-circle-scroll-auto", + "type": "registry:ui", + "componentName": "text-circle-auto", + "description": "Auto-spin demo (no scroll binding), counter-clockwise.", + "files": [ + { + "path": "text-circle-scroll-auto.tsx", + "content": "import TextCircleScroll from '@/components/core/text-circle-scroll';\n\nexport function TextCircleAuto() {\n const words = Array.from(\n { length: 16 },\n (_, i) =>\n (['Aurora', 'Lullaby', 'Labyrinth', 'Idyllic', 'Felicity'] as const)[\n i % 5\n ]\n );\n\n return (\n
    \n \n
    \n );\n}\n", + "type": "registry:component" + }, + { + "path": "components/core/text-circle-scroll.tsx", + "content": "'use client';\n\nimport * as React from 'react';\nimport { motion, useScroll, useTransform, useSpring } from 'motion/react';\nimport { cn } from '@/lib/utils';\n\nexport type TextCircleItem = string | React.ReactNode;\n\nexport type TextCircleScrollProps = {\n /** Items placed on the ring in order (clockwise by default). */\n items: TextCircleItem[];\n\n /** Radius in px (distance from center to baseline of items). */\n radius?: number;\n\n /** Empty hole diameter in px (purely visual helper class on container). */\n innerGap?: number;\n\n /** Angle offset in degrees for the first item. */\n startAngle?: number;\n\n /** Clockwise (true) or counter-clockwise (false) item order. */\n clockwise?: boolean;\n\n /** If true, connects rotation to page scroll progress. */\n rotateOnScroll?: boolean;\n\n /**\n * How many degrees the ring rotates from top->bottom of the page when\n * rotateOnScroll = true. 360 = one full turn.\n */\n scrollDegrees?: number;\n\n /** Constant rotation (deg/s). Can be negative for reverse. */\n autoSpinDegPerSec?: number;\n\n /** Springiness for scroll rotation. Set 0 to disable spring. */\n springStiffness?: number;\n\n /** Container className */\n className?: string;\n\n /** Ring element className */\n ringClassName?: string;\n\n /** Per-item className */\n itemClassName?: string;\n\n /** Typography class for items (e.g. 'text-lg font-serif') */\n textClassName?: string;\n\n /** If provided, constrain the component height (useful in docs/examples). */\n height?: number | string;\n};\n\n/**\n * TextCircleScroll\n * - Places any text/nodes around a circle\n * - Rotates with scroll (and/or auto-spin)\n * - Purely presentational; does not manage layout outside itself\n */\nexport default function TextCircleScroll({\n items,\n radius = 110,\n innerGap = 90,\n startAngle = -90,\n clockwise = true,\n rotateOnScroll = true,\n scrollDegrees = 360,\n autoSpinDegPerSec = 0,\n springStiffness = 120,\n className,\n ringClassName,\n itemClassName,\n textClassName = 'text-base font-serif text-zinc-800 dark:text-zinc-200',\n height = 320,\n}: TextCircleScrollProps) {\n const ref = React.useRef(null);\n\n // --- Scroll-driven rotation\n const { scrollYProgress } = useScroll({\n // when the component is within the viewport\n target: ref,\n offset: ['start end', 'end start'],\n });\n\n const rawRotate = useTransform(scrollYProgress, [0, 1], [0, scrollDegrees]);\n const rotateSpring = useSpring(rawRotate, {\n stiffness: springStiffness || 120,\n damping: 18,\n mass: 0.6,\n });\n\n // --- Auto-spin via requestAnimationFrame\n const [autoAngle, setAutoAngle] = React.useState(0);\n React.useEffect(() => {\n if (!autoSpinDegPerSec) return;\n let raf = 0;\n let last = performance.now();\n const tick = (t: number) => {\n const dt = (t - last) / 1000;\n last = t;\n setAutoAngle((a) => a + autoSpinDegPerSec * dt);\n raf = requestAnimationFrame(tick);\n };\n raf = requestAnimationFrame(tick);\n return () => cancelAnimationFrame(raf);\n }, [autoSpinDegPerSec]);\n\n // Combine scroll rotation + auto rotation.\n const [combined, setCombined] = React.useState(0);\n React.useEffect(() => {\n const unsub = rotateSpring.on('change', (v) => setCombined(v));\n return () => unsub();\n }, [rotateSpring]);\n\n const totalRotation = (rotateOnScroll ? combined : 0) + autoAngle;\n\n const n = items.length;\n const step = 360 / Math.max(1, n);\n const dir = clockwise ? 1 : -1;\n\n return (\n \n {/* Visual inner hole helper */}\n \n {/* Ring */}\n \n {items.map((item, i) => {\n const angle = startAngle + dir * i * step;\n // Place at angle: rotate container then translate then un-rotate\n const style: React.CSSProperties = {\n position: 'absolute',\n top: '50%',\n left: '50%',\n transform: `rotate(${angle}deg) translate(${radius}px) rotate(${clockwise ? 90 : -90}deg)`,\n transformOrigin: '0 0',\n whiteSpace: 'nowrap',\n };\n return (\n \n \n {typeof item === 'string' ? item : item}\n \n \n );\n })}\n \n \n );\n}\n", + "type": "registry:ui" + } + ] +} \ No newline at end of file diff --git a/public/e/text-circle-scroll-basic.json b/public/e/text-circle-scroll-basic.json new file mode 100644 index 0000000..b67be1a --- /dev/null +++ b/public/e/text-circle-scroll-basic.json @@ -0,0 +1,18 @@ +{ + "name": "text-circle-scroll-basic", + "type": "registry:ui", + "componentName": "text-circle-basic", + "description": "Scroll-driven rotation with serif words around the ring.", + "files": [ + { + "path": "text-circle-scroll-basic.tsx", + "content": "import TextCircleScroll from '@/components/core/text-circle-scroll';\n\nconst WORDS = [\n 'Bungalow',\n 'Aurora',\n 'Lullaby',\n 'Labyrinth',\n 'Idyllic',\n 'Felicity',\n 'Demure',\n 'Chatoyant',\n 'TheseDays',\n 'Demure',\n 'Aurora',\n 'Bungalow',\n];\n\nexport function TextCircleBasic() {\n return (\n
    \n \n
    \n );\n}\n", + "type": "registry:component" + }, + { + "path": "components/core/text-circle-scroll.tsx", + "content": "'use client';\n\nimport * as React from 'react';\nimport { motion, useScroll, useTransform, useSpring } from 'motion/react';\nimport { cn } from '@/lib/utils';\n\nexport type TextCircleItem = string | React.ReactNode;\n\nexport type TextCircleScrollProps = {\n /** Items placed on the ring in order (clockwise by default). */\n items: TextCircleItem[];\n\n /** Radius in px (distance from center to baseline of items). */\n radius?: number;\n\n /** Empty hole diameter in px (purely visual helper class on container). */\n innerGap?: number;\n\n /** Angle offset in degrees for the first item. */\n startAngle?: number;\n\n /** Clockwise (true) or counter-clockwise (false) item order. */\n clockwise?: boolean;\n\n /** If true, connects rotation to page scroll progress. */\n rotateOnScroll?: boolean;\n\n /**\n * How many degrees the ring rotates from top->bottom of the page when\n * rotateOnScroll = true. 360 = one full turn.\n */\n scrollDegrees?: number;\n\n /** Constant rotation (deg/s). Can be negative for reverse. */\n autoSpinDegPerSec?: number;\n\n /** Springiness for scroll rotation. Set 0 to disable spring. */\n springStiffness?: number;\n\n /** Container className */\n className?: string;\n\n /** Ring element className */\n ringClassName?: string;\n\n /** Per-item className */\n itemClassName?: string;\n\n /** Typography class for items (e.g. 'text-lg font-serif') */\n textClassName?: string;\n\n /** If provided, constrain the component height (useful in docs/examples). */\n height?: number | string;\n};\n\n/**\n * TextCircleScroll\n * - Places any text/nodes around a circle\n * - Rotates with scroll (and/or auto-spin)\n * - Purely presentational; does not manage layout outside itself\n */\nexport default function TextCircleScroll({\n items,\n radius = 110,\n innerGap = 90,\n startAngle = -90,\n clockwise = true,\n rotateOnScroll = true,\n scrollDegrees = 360,\n autoSpinDegPerSec = 0,\n springStiffness = 120,\n className,\n ringClassName,\n itemClassName,\n textClassName = 'text-base font-serif text-zinc-800 dark:text-zinc-200',\n height = 320,\n}: TextCircleScrollProps) {\n const ref = React.useRef(null);\n\n // --- Scroll-driven rotation\n const { scrollYProgress } = useScroll({\n // when the component is within the viewport\n target: ref,\n offset: ['start end', 'end start'],\n });\n\n const rawRotate = useTransform(scrollYProgress, [0, 1], [0, scrollDegrees]);\n const rotateSpring = useSpring(rawRotate, {\n stiffness: springStiffness || 120,\n damping: 18,\n mass: 0.6,\n });\n\n // --- Auto-spin via requestAnimationFrame\n const [autoAngle, setAutoAngle] = React.useState(0);\n React.useEffect(() => {\n if (!autoSpinDegPerSec) return;\n let raf = 0;\n let last = performance.now();\n const tick = (t: number) => {\n const dt = (t - last) / 1000;\n last = t;\n setAutoAngle((a) => a + autoSpinDegPerSec * dt);\n raf = requestAnimationFrame(tick);\n };\n raf = requestAnimationFrame(tick);\n return () => cancelAnimationFrame(raf);\n }, [autoSpinDegPerSec]);\n\n // Combine scroll rotation + auto rotation.\n const [combined, setCombined] = React.useState(0);\n React.useEffect(() => {\n const unsub = rotateSpring.on('change', (v) => setCombined(v));\n return () => unsub();\n }, [rotateSpring]);\n\n const totalRotation = (rotateOnScroll ? combined : 0) + autoAngle;\n\n const n = items.length;\n const step = 360 / Math.max(1, n);\n const dir = clockwise ? 1 : -1;\n\n return (\n \n {/* Visual inner hole helper */}\n \n {/* Ring */}\n \n {items.map((item, i) => {\n const angle = startAngle + dir * i * step;\n // Place at angle: rotate container then translate then un-rotate\n const style: React.CSSProperties = {\n position: 'absolute',\n top: '50%',\n left: '50%',\n transform: `rotate(${angle}deg) translate(${radius}px) rotate(${clockwise ? 90 : -90}deg)`,\n transformOrigin: '0 0',\n whiteSpace: 'nowrap',\n };\n return (\n \n \n {typeof item === 'string' ? item : item}\n \n \n );\n })}\n \n \n );\n}\n", + "type": "registry:ui" + } + ] +} \ No newline at end of file diff --git a/public/e/text-circle-scroll-nodes.json b/public/e/text-circle-scroll-nodes.json new file mode 100644 index 0000000..f2fa395 --- /dev/null +++ b/public/e/text-circle-scroll-nodes.json @@ -0,0 +1,18 @@ +{ + "name": "text-circle-scroll-nodes", + "type": "registry:ui", + "componentName": "text-circle-nodes", + "description": "Custom React nodes (uppercase labels) + mixed scroll/auto.", + "files": [ + { + "path": "text-circle-scroll-nodes.tsx", + "content": "import TextCircleScroll from '@/components/core/text-circle-scroll';\n\nexport function TextCircleNodes() {\n const items = [\n \n Design\n ,\n \n Motion\n ,\n \n UI\n ,\n \n DX\n ,\n \n A11y\n ,\n \n Next.js\n ,\n \n Tailwind\n ,\n \n Framer\n ,\n ];\n\n return (\n
    \n \n
    \n );\n}\n", + "type": "registry:component" + }, + { + "path": "components/core/text-circle-scroll.tsx", + "content": "'use client';\n\nimport * as React from 'react';\nimport { motion, useScroll, useTransform, useSpring } from 'motion/react';\nimport { cn } from '@/lib/utils';\n\nexport type TextCircleItem = string | React.ReactNode;\n\nexport type TextCircleScrollProps = {\n /** Items placed on the ring in order (clockwise by default). */\n items: TextCircleItem[];\n\n /** Radius in px (distance from center to baseline of items). */\n radius?: number;\n\n /** Empty hole diameter in px (purely visual helper class on container). */\n innerGap?: number;\n\n /** Angle offset in degrees for the first item. */\n startAngle?: number;\n\n /** Clockwise (true) or counter-clockwise (false) item order. */\n clockwise?: boolean;\n\n /** If true, connects rotation to page scroll progress. */\n rotateOnScroll?: boolean;\n\n /**\n * How many degrees the ring rotates from top->bottom of the page when\n * rotateOnScroll = true. 360 = one full turn.\n */\n scrollDegrees?: number;\n\n /** Constant rotation (deg/s). Can be negative for reverse. */\n autoSpinDegPerSec?: number;\n\n /** Springiness for scroll rotation. Set 0 to disable spring. */\n springStiffness?: number;\n\n /** Container className */\n className?: string;\n\n /** Ring element className */\n ringClassName?: string;\n\n /** Per-item className */\n itemClassName?: string;\n\n /** Typography class for items (e.g. 'text-lg font-serif') */\n textClassName?: string;\n\n /** If provided, constrain the component height (useful in docs/examples). */\n height?: number | string;\n};\n\n/**\n * TextCircleScroll\n * - Places any text/nodes around a circle\n * - Rotates with scroll (and/or auto-spin)\n * - Purely presentational; does not manage layout outside itself\n */\nexport default function TextCircleScroll({\n items,\n radius = 110,\n innerGap = 90,\n startAngle = -90,\n clockwise = true,\n rotateOnScroll = true,\n scrollDegrees = 360,\n autoSpinDegPerSec = 0,\n springStiffness = 120,\n className,\n ringClassName,\n itemClassName,\n textClassName = 'text-base font-serif text-zinc-800 dark:text-zinc-200',\n height = 320,\n}: TextCircleScrollProps) {\n const ref = React.useRef(null);\n\n // --- Scroll-driven rotation\n const { scrollYProgress } = useScroll({\n // when the component is within the viewport\n target: ref,\n offset: ['start end', 'end start'],\n });\n\n const rawRotate = useTransform(scrollYProgress, [0, 1], [0, scrollDegrees]);\n const rotateSpring = useSpring(rawRotate, {\n stiffness: springStiffness || 120,\n damping: 18,\n mass: 0.6,\n });\n\n // --- Auto-spin via requestAnimationFrame\n const [autoAngle, setAutoAngle] = React.useState(0);\n React.useEffect(() => {\n if (!autoSpinDegPerSec) return;\n let raf = 0;\n let last = performance.now();\n const tick = (t: number) => {\n const dt = (t - last) / 1000;\n last = t;\n setAutoAngle((a) => a + autoSpinDegPerSec * dt);\n raf = requestAnimationFrame(tick);\n };\n raf = requestAnimationFrame(tick);\n return () => cancelAnimationFrame(raf);\n }, [autoSpinDegPerSec]);\n\n // Combine scroll rotation + auto rotation.\n const [combined, setCombined] = React.useState(0);\n React.useEffect(() => {\n const unsub = rotateSpring.on('change', (v) => setCombined(v));\n return () => unsub();\n }, [rotateSpring]);\n\n const totalRotation = (rotateOnScroll ? combined : 0) + autoAngle;\n\n const n = items.length;\n const step = 360 / Math.max(1, n);\n const dir = clockwise ? 1 : -1;\n\n return (\n \n {/* Visual inner hole helper */}\n \n {/* Ring */}\n \n {items.map((item, i) => {\n const angle = startAngle + dir * i * step;\n // Place at angle: rotate container then translate then un-rotate\n const style: React.CSSProperties = {\n position: 'absolute',\n top: '50%',\n left: '50%',\n transform: `rotate(${angle}deg) translate(${radius}px) rotate(${clockwise ? 90 : -90}deg)`,\n transformOrigin: '0 0',\n whiteSpace: 'nowrap',\n };\n return (\n \n \n {typeof item === 'string' ? item : item}\n \n \n );\n })}\n \n \n );\n}\n", + "type": "registry:ui" + } + ] +} \ No newline at end of file diff --git a/scripts/registry-components.ts b/scripts/registry-components.ts index 697deb4..d228308 100644 --- a/scripts/registry-components.ts +++ b/scripts/registry-components.ts @@ -130,17 +130,18 @@ export const components: ComponentDefinition[] = [ 'Outlined pricing card with colored frame, icon, price and feature list.', }, { - name: 'chevron-steps', - path: path.join(__dirname, '../components/core/chevron-steps.tsx'), - registryDependencies: [], - dependencies: [], - description: 'Arrowed step progress bar with clickable segments.' -}, -{ - name: 'text-circle-scroll', - path: path.join(__dirname, '../components/core/text-circle-scroll.tsx'), - registryDependencies: [], - dependencies: ['motion'], - description: 'Circular text/nodes that rotate with scroll and/or auto-spin.', -}, + name: 'chevron-steps', + path: path.join(__dirname, '../components/core/chevron-steps.tsx'), + registryDependencies: [], + dependencies: [], + description: 'Arrowed step progress bar with clickable segments.', + }, + { + name: 'text-circle-scroll', + path: path.join(__dirname, '../components/core/text-circle-scroll.tsx'), + registryDependencies: [], + dependencies: ['motion'], + description: + 'Circular text/nodes that rotate with scroll and/or auto-spin.', + }, ]; diff --git a/scripts/registry-examples.ts b/scripts/registry-examples.ts index 1510cd0..7abb3a3 100644 --- a/scripts/registry-examples.ts +++ b/scripts/registry-examples.ts @@ -457,35 +457,59 @@ export const examples: ExampleDefinition[] = [ ], }, { - name: 'chevron-steps-basic', - path: path.join(__dirname, '../app/docs/chevron-steps/chevron-steps-basic.tsx'), - description: 'Brand variant like the reference screenshot.', - componentName: 'chevron-steps-basic', - files: [ - { name: 'chevron-steps.tsx', path: path.join(__dirname, '../components/core/chevron-steps.tsx'), type: 'registry:ui' }, - ], -}, -{ - name: 'chevron-steps-progress', - path: path.join(__dirname, '../app/docs/chevron-steps/chevron-steps-progress.tsx'), - description: 'Interactive progress with Prev/Next.', - componentName: 'chevron-steps-progress', - files: [ - { name: 'chevron-steps.tsx', path: path.join(__dirname, '../components/core/chevron-steps.tsx'), type: 'registry:ui' }, - ], -}, -{ - name: 'chevron-steps-neutral', - path: path.join(__dirname, '../app/docs/chevron-steps/chevron-steps-neutral.tsx'), - description: 'Neutral, large variant.', - componentName: 'chevron-steps-neutral', - files: [ - { name: 'chevron-steps.tsx', path: path.join(__dirname, '../components/core/chevron-steps.tsx'), type: 'registry:ui' }, - ], -}, -{ + name: 'chevron-steps-basic', + path: path.join( + __dirname, + '../app/docs/chevron-steps/chevron-steps-basic.tsx' + ), + description: 'Brand variant like the reference screenshot.', + componentName: 'chevron-steps-basic', + files: [ + { + name: 'chevron-steps.tsx', + path: path.join(__dirname, '../components/core/chevron-steps.tsx'), + type: 'registry:ui', + }, + ], + }, + { + name: 'chevron-steps-progress', + path: path.join( + __dirname, + '../app/docs/chevron-steps/chevron-steps-progress.tsx' + ), + description: 'Interactive progress with Prev/Next.', + componentName: 'chevron-steps-progress', + files: [ + { + name: 'chevron-steps.tsx', + path: path.join(__dirname, '../components/core/chevron-steps.tsx'), + type: 'registry:ui', + }, + ], + }, + { + name: 'chevron-steps-neutral', + path: path.join( + __dirname, + '../app/docs/chevron-steps/chevron-steps-neutral.tsx' + ), + description: 'Neutral, large variant.', + componentName: 'chevron-steps-neutral', + files: [ + { + name: 'chevron-steps.tsx', + path: path.join(__dirname, '../components/core/chevron-steps.tsx'), + type: 'registry:ui', + }, + ], + }, + { name: 'text-circle-scroll-basic', - path: path.join(__dirname, '../app/docs/text-circle-scroll/text-circle-basic.tsx'), + path: path.join( + __dirname, + '../app/docs/text-circle-scroll/text-circle-basic.tsx' + ), description: 'Scroll-driven rotation with serif words around the ring.', componentName: 'text-circle-basic', files: [ @@ -498,7 +522,10 @@ export const examples: ExampleDefinition[] = [ }, { name: 'text-circle-scroll-auto', - path: path.join(__dirname, '../app/docs/text-circle-scroll/text-circle-auto.tsx'), + path: path.join( + __dirname, + '../app/docs/text-circle-scroll/text-circle-auto.tsx' + ), description: 'Auto-spin demo (no scroll binding), counter-clockwise.', componentName: 'text-circle-auto', files: [ @@ -511,7 +538,10 @@ export const examples: ExampleDefinition[] = [ }, { name: 'text-circle-scroll-nodes', - path: path.join(__dirname, '../app/docs/text-circle-scroll/text-circle-nodes.tsx'), + path: path.join( + __dirname, + '../app/docs/text-circle-scroll/text-circle-nodes.tsx' + ), description: 'Custom React nodes (uppercase labels) + mixed scroll/auto.', componentName: 'text-circle-nodes', files: [ @@ -522,6 +552,4 @@ export const examples: ExampleDefinition[] = [ }, ], }, - - ]; diff --git a/stories/core/ChevronSteps.stories.tsx b/stories/core/ChevronSteps.stories.tsx index 030a447..b413da8 100644 --- a/stories/core/ChevronSteps.stories.tsx +++ b/stories/core/ChevronSteps.stories.tsx @@ -4,7 +4,10 @@ import ChevronSteps from '@/components/core/chevron-steps'; import type { ChevronStep } from '@/components/core/chevron-steps'; import { action } from '@storybook/addon-actions'; -const stepClicked = action('step-click') as (index: number, step: ChevronStep) => void; +const stepClicked = action('step-click') as ( + index: number, + step: ChevronStep +) => void; const meta: Meta = { title: 'Core/Navigation/Chevron Steps', @@ -21,7 +24,10 @@ const meta: Meta = { }, argTypes: { steps: { control: 'object', description: 'Array of steps in order.' }, - current: { control: { type: 'number', min: 0 }, description: 'Zero-based active index.' }, + current: { + control: { type: 'number', min: 0 }, + description: 'Zero-based active index.', + }, size: { control: 'inline-radio', options: ['sm', 'md', 'lg'] }, variant: { control: 'inline-radio', options: ['brand', 'neutral'] }, radius: { control: 'inline-radio', options: ['md', 'lg', 'xl', '2xl'] }, @@ -70,13 +76,13 @@ export const Playground: Story = { export const LikeScreenshot: Story = { render: () => ( -
    +
    @@ -89,12 +95,12 @@ export const LikeScreenshot: Story = { export const NeutralLarge: Story = { render: () => ( -
    +
    @@ -108,13 +114,13 @@ export const NeutralLarge: Story = { export const ScrollableMany: Story = { render: () => ( -
    +
    ({ label: i === 3 ? `Step ${i + 1} with long label` : `Step ${i + 1}`, }))} current={4} - size="md" + size='md' tailWidth={18} onStepClick={stepClicked} /> @@ -128,7 +134,7 @@ export const ScrollableMany: Story = { export const WithDisabled: Story = { render: () => ( -
    +
    ) => { ]; return ( -
    +
    ) => { if (!s.disabled) setIdx(i); }} /> -
    +