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..c19a876 --- /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..80697e8 --- /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..a650a31 --- /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..615b54f --- /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..6067f92 100644 --- a/app/docs/navigation.ts +++ b/app/docs/navigation.ts @@ -27,15 +27,21 @@ export const NAVIGATION: NavigationGroup[] = [ { name: 'Core Components', children: [ - { name: 'Timeline Rail', href: '/docs/timeline-rail', isNew: true }, - { name: 'Testimonial', href: '/docs/testimonial', isUpdated: true }, + { name: 'Timeline Rail', href: '/docs/timeline-rail' }, + { name: 'Testimonial', href: '/docs/testimonial' }, + { name: 'Chevron Steps', href: '/docs/chevron-steps', isNew: true }, + { + name: 'Text Circle Scroll', + href: '/docs/text-circle-scroll', + isNew: true, + }, ], }, { name: 'Pricing Card', children: [ - { name: 'Pricing Card One', href: '/docs/pricing-card-one', isNew: true }, - { name: 'Pricing Card Two', href: '/docs/pricing-card-two', isNew: true }, + { name: 'Pricing Card One', href: '/docs/pricing-card-one' }, + { name: 'Pricing Card Two', href: '/docs/pricing-card-two' }, ], }, { @@ -48,7 +54,7 @@ export const NAVIGATION: NavigationGroup[] = [ { name: 'Leaderboard Card', href: '/docs/leaderboard-card', - isNew: true, + isUpdated: true, }, ], }, @@ -78,8 +84,8 @@ export const NAVIGATION: NavigationGroup[] = [ href: '/docs/blog-card-one', }, { name: 'Blog Card Two', href: '/docs/blog-card-two' }, - { name: 'Blog Card Three', href: '/docs/blog-card-three', isNew: true }, - { name: 'Blog Card Four', href: '/docs/blog-card-four', isNew: true }, + { name: 'Blog Card Three', href: '/docs/blog-card-three' }, + { name: 'Blog Card Four', href: '/docs/blog-card-four' }, ], }, ]; diff --git a/app/docs/text-circle-scroll/page.mdx b/app/docs/text-circle-scroll/page.mdx new file mode 100644 index 0000000..4c5bfa4 --- /dev/null +++ b/app/docs/text-circle-scroll/page.mdx @@ -0,0 +1,74 @@ +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..6ab6caa --- /dev/null +++ b/app/docs/text-circle-scroll/text-circle-auto.tsx @@ -0,0 +1,27 @@ +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 ( +
+ +
+ ); +} 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..62074d9 --- /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..a585eff --- /dev/null +++ b/app/docs/text-circle-scroll/text-circle-nodes.tsx @@ -0,0 +1,46 @@ +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/cli/package-lock.json b/cli/package-lock.json index bfbfaa4..9ec372a 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "shadcn-extras", - "version": "0.3.6", + "version": "0.3.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "shadcn-extras", - "version": "0.3.6", + "version": "0.3.7", "license": "MIT", "dependencies": { "commander": "^9.5.0", diff --git a/cli/package.json b/cli/package.json index 653b1d0..596975b 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "shadcn-extras", - "version": "0.3.6", + "version": "0.3.7", "description": "CLI to add shadcn-extras components to your app", "bin": { "shadcn-extras": "./dist/index.js" diff --git a/components/core/chevron-steps.tsx b/components/core/chevron-steps.tsx new file mode 100644 index 0000000..70efb6e --- /dev/null +++ b/components/core/chevron-steps.tsx @@ -0,0 +1,225 @@ +'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..7596487 --- /dev/null +++ b/components/core/text-circle-scroll.tsx @@ -0,0 +1,175 @@ +'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/public/c/chevron-steps.json b/public/c/chevron-steps.json new file mode 100644 index 0000000..2b8fe4f --- /dev/null +++ b/public/c/chevron-steps.json @@ -0,0 +1,19 @@ +{ + "name": "chevron-steps", + "type": "registry:ui", + "registryDependencies": [], + "dependencies": [], + "devDependencies": [], + "tailwind": {}, + "cssVars": { + "light": {}, + "dark": {} + }, + "files": [ + { + "path": "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/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..683e6ea 100644 --- a/public/c/registry.json +++ b/public/c/registry.json @@ -264,6 +264,46 @@ "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" + } + ], + "categories": [ + "ui", + "shadcn-extras" + ] } ] } \ No newline at end of file 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 b1bdf7c..d228308 100644 --- a/scripts/registry-components.ts +++ b/scripts/registry-components.ts @@ -129,4 +129,19 @@ 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..7abb3a3 100644 --- a/scripts/registry-examples.ts +++ b/scripts/registry-examples.ts @@ -456,4 +456,100 @@ 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..b413da8 --- /dev/null +++ b/stories/core/ChevronSteps.stories.tsx @@ -0,0 +1,201 @@ +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..db68dc3 --- /dev/null +++ b/stories/core/TextCircleScroll.stories.tsx @@ -0,0 +1,175 @@ +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' + /> +
    + ), +};