From f3ca82523ab195302729a3d4da47126d450e3ad2 Mon Sep 17 00:00:00 2001 From: moshiur01 Date: Thu, 8 Oct 2026 12:14:35 +0600 Subject: [PATCH 1/3] feat(registry): add the arrow tile and focus frame buttons Arrow Tile Button pairs a label slab with a detached arrow tile in two variants. In tilt, the tile knocks the slab and the letters topple in a wave away from it before springing upright. In swap, the tile rolls to the front of the label while the letters lift in its wake. Both squash on press. Focus Frame Button sits inside a hairline frame that folds back into its corners on hover while the corner brackets snap shut around the button. The label rolls up to a fresh copy and a new return arrow slides in. Both are pure CSS, with a demo, docs page and tests. --- .../content/1.component/arrow-tile-button.mdx | 75 +++++++++ .../1.component/focus-frame-button.mdx | 72 +++++++++ .../docs/public/r/arrow-tile-button-demo.json | 17 ++ apps/docs/public/r/arrow-tile-button.json | 21 +++ .../public/r/focus-frame-button-demo.json | 17 ++ apps/docs/public/r/focus-frame-button.json | 21 +++ apps/docs/public/r/registry.json | 48 ++++++ apps/docs/registry/__index__.generated.tsx | 2 + apps/docs/registry/__sources__.generated.ts | 24 +++ .../docs/registry/demos/arrow-tile-button.tsx | 10 ++ .../registry/demos/focus-frame-button.tsx | 5 + apps/docs/registry/registry-ui.ts | 66 ++++++++ .../registry/tweenui/arrow-tile-button.tsx | 153 ++++++++++++++++++ .../registry/tweenui/focus-frame-button.tsx | 96 +++++++++++ apps/docs/tests/arrow-tile-button.test.tsx | 75 +++++++++ apps/docs/tests/focus-frame-button.test.tsx | 56 +++++++ 16 files changed, 758 insertions(+) create mode 100644 apps/docs/content/1.component/arrow-tile-button.mdx create mode 100644 apps/docs/content/1.component/focus-frame-button.mdx create mode 100644 apps/docs/public/r/arrow-tile-button-demo.json create mode 100644 apps/docs/public/r/arrow-tile-button.json create mode 100644 apps/docs/public/r/focus-frame-button-demo.json create mode 100644 apps/docs/public/r/focus-frame-button.json create mode 100644 apps/docs/registry/demos/arrow-tile-button.tsx create mode 100644 apps/docs/registry/demos/focus-frame-button.tsx create mode 100644 apps/docs/registry/tweenui/arrow-tile-button.tsx create mode 100644 apps/docs/registry/tweenui/focus-frame-button.tsx create mode 100644 apps/docs/tests/arrow-tile-button.test.tsx create mode 100644 apps/docs/tests/focus-frame-button.test.tsx diff --git a/apps/docs/content/1.component/arrow-tile-button.mdx b/apps/docs/content/1.component/arrow-tile-button.mdx new file mode 100644 index 0000000..f6f53fb --- /dev/null +++ b/apps/docs/content/1.component/arrow-tile-button.mdx @@ -0,0 +1,75 @@ +--- +title: Arrow Tile Button +description: A label slab paired with a detached arrow tile, in a springy tilt and a sliding swap variant. +icon: square-arrow-right +--- + +A label slab sits beside its own **square arrow tile**, and the pair animates +two ways. In `tilt`, the tile **knocks the word**: it leans into the slab, the +slab is shoved back and springs into place, and the letters **topple in a wave** +that travels away from the tile before bouncing upright. In `swap`, the tile **takes the lead**: it rolls across to the front of the +label, its arrow spinning a full turn, while the label slides aside and its +letters **lift in a wake** behind it. Pressing either variant squashes the whole +button. It is a real `\n );\n}\n" + } + ], + "docs": "Docs, props and examples: https://tween-ui.vercel.app/component/arrow-tile-button" +} \ No newline at end of file diff --git a/apps/docs/public/r/focus-frame-button-demo.json b/apps/docs/public/r/focus-frame-button-demo.json new file mode 100644 index 0000000..9c24fe6 --- /dev/null +++ b/apps/docs/public/r/focus-frame-button-demo.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "focus-frame-button-demo", + "type": "registry:example", + "title": "Focus Frame Button Demo", + "description": "Usage examples for Focus Frame Button.", + "registryDependencies": [ + "https://tween-ui.vercel.app/r/focus-frame-button.json" + ], + "files": [ + { + "path": "examples/focus-frame-button-demo.tsx", + "type": "registry:example", + "content": "import FocusFrameButton from '@/components/tweenui/focus-frame-button';\n\nexport default function Page() {\n return (\n console.log('clicked')}>Talk to the team\n );\n}" + } + ] +} \ No newline at end of file diff --git a/apps/docs/public/r/focus-frame-button.json b/apps/docs/public/r/focus-frame-button.json new file mode 100644 index 0000000..cca7496 --- /dev/null +++ b/apps/docs/public/r/focus-frame-button.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "focus-frame-button", + "type": "registry:ui", + "title": "Focus Frame Button", + "description": "A framed button that locks on like a viewfinder on hover: the hairline folds into its corners, the brackets snap shut while the label rolls up and a fresh return arrow slides in.", + "dependencies": [], + "registryDependencies": [], + "cssVars": { + "theme": {} + }, + "files": [ + { + "path": "components/tweenui/focus-frame-button.tsx", + "type": "registry:component", + "target": "components/tweenui/focus-frame-button.tsx", + "content": "import type { ComponentPropsWithRef } from 'react';\nimport { cn } from '@/lib/utils';\n\nexport interface FocusFrameButtonProps extends ComponentPropsWithRef<'button'> {\n children: string;\n}\n\nconst roll =\n 'block transition-transform duration-600 ease-(--expo) group-hover:-translate-y-full group-focus-visible:-translate-y-full motion-reduce:transition-none';\n\nfunction RollLabel({ children }: { children: string }) {\n return (\n \n {children}\n \n {children}\n \n \n );\n}\n\nfunction ReturnArrow({ className }: { className?: string }) {\n return (\n \n \n \n );\n}\n\nconst slide =\n '-translate-x-full transition-transform duration-600 ease-(--expo) group-hover:translate-x-0 group-focus-visible:translate-x-0 motion-reduce:transition-none';\n\nconst rail =\n 'pointer-events-none absolute bg-[#045f64]/35 transition-transform delay-200 duration-500 ease-(--expo) group-hover:delay-0 group-focus-visible:delay-0 motion-reduce:transition-none dark:bg-[#c6f56f]/35';\n\nconst rails = [\n 'top-0 left-0 h-px w-1/2 origin-left group-hover:scale-x-0 group-focus-visible:scale-x-0',\n 'top-0 right-0 h-px w-1/2 origin-right group-hover:scale-x-0 group-focus-visible:scale-x-0',\n 'bottom-0 left-0 h-px w-1/2 origin-left group-hover:scale-x-0 group-focus-visible:scale-x-0',\n 'right-0 bottom-0 h-px w-1/2 origin-right group-hover:scale-x-0 group-focus-visible:scale-x-0',\n 'top-0 left-0 h-1/2 w-px origin-top group-hover:scale-y-0 group-focus-visible:scale-y-0',\n 'bottom-0 left-0 h-1/2 w-px origin-bottom group-hover:scale-y-0 group-focus-visible:scale-y-0',\n 'top-0 right-0 h-1/2 w-px origin-top group-hover:scale-y-0 group-focus-visible:scale-y-0',\n 'right-0 bottom-0 h-1/2 w-px origin-bottom group-hover:scale-y-0 group-focus-visible:scale-y-0',\n];\n\nconst corner =\n 'pointer-events-none absolute size-2.5 border-[#045f64] transition-transform duration-500 ease-(--expo) group-hover:delay-200 group-hover:duration-700 group-hover:ease-(--snap) group-focus-visible:delay-200 group-focus-visible:duration-700 group-focus-visible:ease-(--snap) motion-reduce:transition-none dark:border-[#c6f56f]';\n\nconst corners = [\n 'top-0 left-0 border-t-[1.5px] border-l-[1.5px] group-hover:translate-1 group-focus-visible:translate-1',\n 'top-0 right-0 border-t-[1.5px] border-r-[1.5px] group-hover:-translate-x-1 group-hover:translate-y-1 group-focus-visible:-translate-x-1 group-focus-visible:translate-y-1',\n 'bottom-0 left-0 border-b-[1.5px] border-l-[1.5px] group-hover:translate-x-1 group-hover:-translate-y-1 group-focus-visible:translate-x-1 group-focus-visible:-translate-y-1',\n 'right-0 bottom-0 border-r-[1.5px] border-b-[1.5px] group-hover:-translate-1 group-focus-visible:-translate-1',\n];\n\nexport default function FocusFrameButton({\n children,\n className,\n type = 'button',\n ...props\n}: FocusFrameButtonProps) {\n return (\n \n {rails.map((placement) => (\n \n ))}\n {corners.map((placement) => (\n \n ))}\n \n {children}\n \n \n \n \n \n \n );\n}\n" + } + ], + "docs": "Docs, props and examples: https://tween-ui.vercel.app/component/focus-frame-button" +} \ No newline at end of file diff --git a/apps/docs/public/r/registry.json b/apps/docs/public/r/registry.json index 931081d..12f1097 100644 --- a/apps/docs/public/r/registry.json +++ b/apps/docs/public/r/registry.json @@ -343,6 +343,54 @@ } ] }, + { + "name": "arrow-tile-button", + "type": "registry:ui", + "title": "Arrow Tile Button", + "description": "A label slab paired with a detached arrow tile: the tilt variant has the tile knock the slab so its letters topple in a wave away from it and spring upright, and the swap variant rolls the tile to the front as the letters lift in its wake.", + "files": [ + { + "path": "components/tweenui/arrow-tile-button.tsx", + "type": "registry:component" + } + ] + }, + { + "name": "arrow-tile-button-demo", + "type": "registry:example", + "title": "Arrow Tile Button Demo", + "description": "Usage examples for Arrow Tile Button.", + "files": [ + { + "path": "examples/arrow-tile-button-demo.tsx", + "type": "registry:example" + } + ] + }, + { + "name": "focus-frame-button", + "type": "registry:ui", + "title": "Focus Frame Button", + "description": "A framed button that locks on like a viewfinder on hover: the hairline folds into its corners, the brackets snap shut while the label rolls up and a fresh return arrow slides in.", + "files": [ + { + "path": "components/tweenui/focus-frame-button.tsx", + "type": "registry:component" + } + ] + }, + { + "name": "focus-frame-button-demo", + "type": "registry:example", + "title": "Focus Frame Button Demo", + "description": "Usage examples for Focus Frame Button.", + "files": [ + { + "path": "examples/focus-frame-button-demo.tsx", + "type": "registry:example" + } + ] + }, { "name": "logo-orbit", "type": "registry:ui", diff --git a/apps/docs/registry/__index__.generated.tsx b/apps/docs/registry/__index__.generated.tsx index f4df9cc..8ce4b0a 100644 --- a/apps/docs/registry/__index__.generated.tsx +++ b/apps/docs/registry/__index__.generated.tsx @@ -24,6 +24,8 @@ export const demos: Record = { 'glow-button:default': dynamic(() => import('./demos/glow-button')), 'magnetic-button:default': dynamic(() => import('./demos/magnetic-button')), 'dot-bloom-button:default': dynamic(() => import('./demos/dot-bloom-button')), + 'arrow-tile-button:default': dynamic(() => import('./demos/arrow-tile-button')), + 'focus-frame-button:default': dynamic(() => import('./demos/focus-frame-button')), 'logo-orbit:default': dynamic(() => import('./demos/logo-orbit')), 'logo-cycle:default': dynamic(() => import('./demos/logo-cycle')), 'logo-wave:default': dynamic(() => import('./demos/logo-wave')), diff --git a/apps/docs/registry/__sources__.generated.ts b/apps/docs/registry/__sources__.generated.ts index 2ff4755..ce16702 100644 --- a/apps/docs/registry/__sources__.generated.ts +++ b/apps/docs/registry/__sources__.generated.ts @@ -91,6 +91,18 @@ export const usage: Record = { "codeHtml": "
import DotBloomButton from '@/components/tweenui/dot-bloom-button';\n\nexport default function Example() {\n  return <DotBloomButton onClick={() => {}}>Get started</DotBloomButton>;\n}
" } }, + "arrow-tile-button": { + "react": { + "code": "import ArrowTileButton from '@/components/tweenui/arrow-tile-button';\n\nexport default function Example() {\n return {}}>Get started;\n}", + "codeHtml": "
import ArrowTileButton from '@/components/tweenui/arrow-tile-button';\n\nexport default function Example() {\n  return <ArrowTileButton onClick={() => {}}>Get started</ArrowTileButton>;\n}
" + } + }, + "focus-frame-button": { + "react": { + "code": "import FocusFrameButton from '@/components/tweenui/focus-frame-button';\n\nexport default function Example() {\n return {}}>Talk to the team;\n}", + "codeHtml": "
import FocusFrameButton from '@/components/tweenui/focus-frame-button';\n\nexport default function Example() {\n  return <FocusFrameButton onClick={() => {}}>Talk to the team</FocusFrameButton>;\n}
" + } + }, "logo-orbit": { "react": { "code": "import LogoOrbit from '@/components/tweenui/logo-orbit';\n\nconst logos = [\n { src: '/logos/slack.svg', alt: 'Slack' },\n { src: '/logos/figma.svg', alt: 'Figma' },\n { src: '/logos/github.svg', alt: 'GitHub' },\n];\n\nexport default function Example() {\n return ;\n}", @@ -388,6 +400,18 @@ export const manual: Record = { "codeHtml": "
import type { ComponentPropsWithRef } from 'react';\nimport { cn } from '@/lib/utils';\n\nexport interface DotBloomButtonProps extends ComponentPropsWithRef<'button'> {\n  children: string;\n  variant?: 'primary' | 'secondary';\n}\n\nconst variants = {\n  primary:\n    'bg-[#045f64] text-[#c6f56f] [--bloom:#c6f56f] hover:text-[#045f64] focus-visible:text-[#045f64]',\n  secondary:\n    'bg-[#c6f56f] text-[#045f64] [--bloom:#045f64] hover:text-[#c6f56f] focus-visible:text-[#c6f56f]',\n};\n\nconst roll =\n  'block transition-transform duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] group-hover:-translate-y-full group-focus-visible:-translate-y-full motion-reduce:transition-none motion-reduce:group-hover:translate-y-0 motion-reduce:group-focus-visible:translate-y-0';\n\nexport default function DotBloomButton({\n  children,\n  className,\n  variant = 'primary',\n  type = 'button',\n  ...props\n}: DotBloomButtonProps) {\n  return (\n    <button\n      type={type}\n      className={cn(\n        'group relative isolate inline-flex h-11 cursor-pointer items-center justify-center gap-2 overflow-hidden rounded-md px-5 text-sm font-medium tracking-wide whitespace-nowrap uppercase transition-colors duration-400 ease-[cubic-bezier(0.65,0,0.35,1)] focus-visible:ring-2 focus-visible:ring-[#045f64] focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-60 motion-reduce:transition-none dark:ring-offset-[#12161F]',\n        variants[variant],\n        className\n      )}\n      {...props}\n    >\n      <span aria-hidden=\"true\" className=\"size-1.5 flex-none rounded-full bg-current text-left\">\n        <span className=\"pointer-events-none absolute -z-10 aspect-square w-[calc(200%+100px)] -translate-x-[calc(50%-3px)] -translate-y-[calc(50%-3px)] scale-0 rounded-full bg-(--bloom) transition-transform duration-650 ease-[cubic-bezier(0.65,0,0.35,1)] group-hover:scale-100 group-focus-visible:scale-100 motion-reduce:transition-none\" />\n      </span>\n      <span className=\"relative block overflow-hidden leading-5\">\n        <span className={roll}>{children}</span>\n        <span\n          aria-hidden=\"true\"\n          className={cn(roll, 'absolute top-full left-0 motion-reduce:hidden')}\n        >\n          {children}\n        </span>\n      </span>\n    </button>\n  );\n}\n
" } }, + "arrow-tile-button": { + "react": { + "code": "import type { ComponentPropsWithRef, CSSProperties } from 'react';\nimport { cn } from '@/lib/utils';\n\nexport interface ArrowTileButtonProps extends ComponentPropsWithRef<'button'> {\n children: string;\n variant?: 'tilt' | 'swap';\n}\n\nfunction Arrow({ className }: { className?: string }) {\n return (\n \n \n \n );\n}\n\ninterface Wave {\n leadMs: number;\n spreadMs: number;\n}\n\nfunction waveDelay(index: number, count: number, { leadMs, spreadMs }: Wave): CSSProperties {\n const step = count > 1 ? spreadMs / (count - 1) : 0;\n const style: CSSProperties & { '--hop-delay': string } = {\n '--hop-delay': `${leadMs + Math.round((count - 1 - index) * step)}ms`,\n };\n return style;\n}\n\nconst toppleChar =\n 'inline-block whitespace-pre transition-none group-[:is(:hover,:focus-visible)]:[translate:0_45%] group-[:is(:hover,:focus-visible)]:[rotate:-14deg] group-[:is(:hover,:focus-visible)]:[scale:1_0.35] group-[:is(:hover,:focus-visible)]:[transform:scale(1,2.8571)_rotate(14deg)_translateY(-45%)] group-[:is(:hover,:focus-visible)]:[transition:translate_145ms_var(--dip)_var(--hop-delay),rotate_145ms_var(--dip)_var(--hop-delay),scale_145ms_var(--dip)_var(--hop-delay),transform_580ms_var(--spring)_calc(var(--hop-delay)+145ms)] motion-reduce:transition-none';\n\nconst liftChar =\n 'inline-block whitespace-pre transition-none group-[:is(:hover,:focus-visible)]:[translate:0_-40%] group-[:is(:hover,:focus-visible)]:[rotate:9deg] group-[:is(:hover,:focus-visible)]:[transform:rotate(-9deg)_translateY(40%)] group-[:is(:hover,:focus-visible)]:[transition:translate_160ms_var(--lift)_var(--hop-delay),rotate_160ms_var(--lift)_var(--hop-delay),transform_560ms_var(--spring)_calc(var(--hop-delay)+160ms)] motion-reduce:transition-none';\n\nfunction WaveLabel({\n children,\n charClassName,\n wave,\n}: {\n children: string;\n charClassName: string;\n wave: Wave;\n}) {\n const chars = Array.from(children);\n return (\n <>\n {children}\n \n {chars.map((char, index) => (\n \n {char}\n \n ))}\n \n \n );\n}\n\nconst TOPPLE_WAVE: Wave = { leadMs: 140, spreadMs: 225 };\nconst LIFT_WAVE: Wave = { leadMs: 40, spreadMs: 340 };\n\nconst knockRelease =\n 'grid [transition:translate_150ms_var(--soft),rotate_150ms_var(--soft),scale_150ms_var(--soft),transform_150ms_var(--soft)] motion-reduce:transition-none';\n\nfunction TiltContent({ children }: { children: string }) {\n return (\n <>\n \n \n \n \n {children}\n \n \n \n \n \n \n \n \n \n \n );\n}\n\nfunction SwapContent({ children }: { children: string }) {\n return (\n <>\n \n \n {children}\n \n \n \n \n
\n \n );\n}\n\nconst variants = {\n tilt: 'text-[#c6f56f] [--bounce:cubic-bezier(0.34,2.27,0.64,1)] [--dip:cubic-bezier(0.55,0.085,0.68,0.53)] [--soft:cubic-bezier(0.59,1,0.88,1.01)]',\n swap: 'pr-[2.875rem] [--circ:cubic-bezier(0.785,0.135,0.15,0.86)] [--lift:cubic-bezier(0.33,1,0.68,1)]',\n};\n\nexport default function ArrowTileButton({\n children,\n className,\n variant = 'tilt',\n type = 'button',\n ...props\n}: ArrowTileButtonProps) {\n return (\n \n {variant === 'swap' ? (\n {children}\n ) : (\n {children}\n )}\n \n );\n}\n", + "codeHtml": "
import type { ComponentPropsWithRef, CSSProperties } from 'react';\nimport { cn } from '@/lib/utils';\n\nexport interface ArrowTileButtonProps extends ComponentPropsWithRef<'button'> {\n  children: string;\n  variant?: 'tilt' | 'swap';\n}\n\nfunction Arrow({ className }: { className?: string }) {\n  return (\n    <svg\n      viewBox=\"0 0 16 16\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={1.75}\n      strokeLinecap=\"square\"\n      aria-hidden=\"true\"\n      className={cn('size-4 flex-none', className)}\n    >\n      <path d=\"M2.5 8h10M8.5 3.5 13 8l-4.5 4.5\" />\n    </svg>\n  );\n}\n\ninterface Wave {\n  leadMs: number;\n  spreadMs: number;\n}\n\nfunction waveDelay(index: number, count: number, { leadMs, spreadMs }: Wave): CSSProperties {\n  const step = count > 1 ? spreadMs / (count - 1) : 0;\n  const style: CSSProperties & { '--hop-delay': string } = {\n    '--hop-delay': `${leadMs + Math.round((count - 1 - index) * step)}ms`,\n  };\n  return style;\n}\n\nconst toppleChar =\n  'inline-block whitespace-pre transition-none group-[:is(:hover,:focus-visible)]:[translate:0_45%] group-[:is(:hover,:focus-visible)]:[rotate:-14deg] group-[:is(:hover,:focus-visible)]:[scale:1_0.35] group-[:is(:hover,:focus-visible)]:[transform:scale(1,2.8571)_rotate(14deg)_translateY(-45%)] group-[:is(:hover,:focus-visible)]:[transition:translate_145ms_var(--dip)_var(--hop-delay),rotate_145ms_var(--dip)_var(--hop-delay),scale_145ms_var(--dip)_var(--hop-delay),transform_580ms_var(--spring)_calc(var(--hop-delay)+145ms)] motion-reduce:transition-none';\n\nconst liftChar =\n  'inline-block whitespace-pre transition-none group-[:is(:hover,:focus-visible)]:[translate:0_-40%] group-[:is(:hover,:focus-visible)]:[rotate:9deg] group-[:is(:hover,:focus-visible)]:[transform:rotate(-9deg)_translateY(40%)] group-[:is(:hover,:focus-visible)]:[transition:translate_160ms_var(--lift)_var(--hop-delay),rotate_160ms_var(--lift)_var(--hop-delay),transform_560ms_var(--spring)_calc(var(--hop-delay)+160ms)] motion-reduce:transition-none';\n\nfunction WaveLabel({\n  children,\n  charClassName,\n  wave,\n}: {\n  children: string;\n  charClassName: string;\n  wave: Wave;\n}) {\n  const chars = Array.from(children);\n  return (\n    <>\n      <span className=\"sr-only\">{children}</span>\n      <span aria-hidden=\"true\">\n        {chars.map((char, index) => (\n          <span key={index} className={charClassName} style={waveDelay(index, chars.length, wave)}>\n            {char}\n          </span>\n        ))}\n      </span>\n    </>\n  );\n}\n\nconst TOPPLE_WAVE: Wave = { leadMs: 140, spreadMs: 225 };\nconst LIFT_WAVE: Wave = { leadMs: 40, spreadMs: 340 };\n\nconst knockRelease =\n  'grid [transition:translate_150ms_var(--soft),rotate_150ms_var(--soft),scale_150ms_var(--soft),transform_150ms_var(--soft)] motion-reduce:transition-none';\n\nfunction TiltContent({ children }: { children: string }) {\n  return (\n    <>\n      <span\n        className={cn(\n          knockRelease,\n          'origin-left group-[:is(:hover,:focus-visible)]:[translate:-0.3em_0] group-[:is(:hover,:focus-visible)]:[scale:0.95_1.04] group-[:is(:hover,:focus-visible)]:[transform:scale(1.0526,0.9615)_translateX(0.3em)] group-[:is(:hover,:focus-visible)]:[transition:translate_110ms_var(--dip)_60ms,scale_110ms_var(--dip)_60ms,transform_420ms_170ms_var(--bounce)]'\n        )}\n      >\n        <span aria-hidden=\"true\" className=\"col-start-1 row-start-1 rounded-md bg-[#045f64]\" />\n        <span className=\"relative col-start-1 row-start-1 flex items-center px-5\">\n          <WaveLabel charClassName={toppleChar} wave={TOPPLE_WAVE}>\n            {children}\n          </WaveLabel>\n        </span>\n      </span>\n      <span\n        aria-hidden=\"true\"\n        className={cn(\n          knockRelease,\n          '-ml-px size-11 origin-bottom-left group-[:is(:hover,:focus-visible)]:[translate:-0.2em_0] group-[:is(:hover,:focus-visible)]:[rotate:-12deg] group-[:is(:hover,:focus-visible)]:[transform:rotate(12deg)_translateX(0.2em)] group-[:is(:hover,:focus-visible)]:[transition:translate_120ms_var(--dip),rotate_120ms_var(--dip),transform_400ms_120ms_var(--bounce)]'\n        )}\n      >\n        <span className=\"col-start-1 row-start-1 rounded-md bg-[#045f64]\" />\n        <span className=\"relative col-start-1 row-start-1 grid place-items-center\">\n          <Arrow />\n        </span>\n      </span>\n    </>\n  );\n}\n\nfunction SwapContent({ children }: { children: string }) {\n  return (\n    <>\n      <span className=\"flex items-center rounded-md bg-[#c6f56f] px-5 text-[#045f64] transition-[translate] duration-500 ease-(--circ) group-[:is(:hover,:focus-visible)]:translate-x-[2.875rem] motion-reduce:transition-none\">\n        <WaveLabel charClassName={liftChar} wave={LIFT_WAVE}>\n          {children}\n        </WaveLabel>\n      </span>\n      <span\n        aria-hidden=\"true\"\n        className=\"absolute top-0 right-0 z-10 grid size-11 place-items-center rounded-md bg-[#045f64] text-[#c6f56f] transition-[right] duration-500 ease-(--circ) group-[:is(:hover,:focus-visible)]:right-[calc(100%-2.75rem)] motion-reduce:transition-none\"\n      >\n        <Arrow className=\"transition-[rotate] duration-500 ease-(--circ) group-[:is(:hover,:focus-visible)]:-rotate-360 motion-reduce:transition-none\" />\n      </span>\n    </>\n  );\n}\n\nconst variants = {\n  tilt: 'text-[#c6f56f] [--bounce:cubic-bezier(0.34,2.27,0.64,1)] [--dip:cubic-bezier(0.55,0.085,0.68,0.53)] [--soft:cubic-bezier(0.59,1,0.88,1.01)]',\n  swap: 'pr-[2.875rem] [--circ:cubic-bezier(0.785,0.135,0.15,0.86)] [--lift:cubic-bezier(0.33,1,0.68,1)]',\n};\n\nexport default function ArrowTileButton({\n  children,\n  className,\n  variant = 'tilt',\n  type = 'button',\n  ...props\n}: ArrowTileButtonProps) {\n  return (\n    <button\n      type={type}\n      className={cn(\n        'group relative inline-flex h-11 cursor-pointer items-stretch text-sm font-medium tracking-wide whitespace-nowrap uppercase transition-[scale] duration-150 ease-[cubic-bezier(0.4,0,0.2,1)] select-none [--spring:linear(0,0.406_4.2%,0.855_8.3%,1.161_12.5%,1.273_16.7%,1.234_20.8%,1.125_25%,1.017_29.2%,0.95_33.3%,0.931_37.5%,0.946_41.7%,0.975_45.8%,1_50%,1.014_54.2%,1.017_58.3%,1.012_62.5%,1.005_66.7%,0.999_70.8%,0.996_75%,0.997_83.3%,1_91.7%,1)] focus-visible:ring-2 focus-visible:ring-[#045f64] focus-visible:ring-offset-2 focus-visible:outline-none active:[scale:0.955_0.925] disabled:pointer-events-none disabled:opacity-60 motion-reduce:transition-none dark:ring-offset-[#12161F]',\n        variants[variant],\n        className\n      )}\n      {...props}\n    >\n      {variant === 'swap' ? (\n        <SwapContent>{children}</SwapContent>\n      ) : (\n        <TiltContent>{children}</TiltContent>\n      )}\n    </button>\n  );\n}\n
" + } + }, + "focus-frame-button": { + "react": { + "code": "import type { ComponentPropsWithRef } from 'react';\nimport { cn } from '@/lib/utils';\n\nexport interface FocusFrameButtonProps extends ComponentPropsWithRef<'button'> {\n children: string;\n}\n\nconst roll =\n 'block transition-transform duration-600 ease-(--expo) group-hover:-translate-y-full group-focus-visible:-translate-y-full motion-reduce:transition-none';\n\nfunction RollLabel({ children }: { children: string }) {\n return (\n \n {children}\n \n {children}\n \n \n );\n}\n\nfunction ReturnArrow({ className }: { className?: string }) {\n return (\n \n \n \n );\n}\n\nconst slide =\n '-translate-x-full transition-transform duration-600 ease-(--expo) group-hover:translate-x-0 group-focus-visible:translate-x-0 motion-reduce:transition-none';\n\nconst rail =\n 'pointer-events-none absolute bg-[#045f64]/35 transition-transform delay-200 duration-500 ease-(--expo) group-hover:delay-0 group-focus-visible:delay-0 motion-reduce:transition-none dark:bg-[#c6f56f]/35';\n\nconst rails = [\n 'top-0 left-0 h-px w-1/2 origin-left group-hover:scale-x-0 group-focus-visible:scale-x-0',\n 'top-0 right-0 h-px w-1/2 origin-right group-hover:scale-x-0 group-focus-visible:scale-x-0',\n 'bottom-0 left-0 h-px w-1/2 origin-left group-hover:scale-x-0 group-focus-visible:scale-x-0',\n 'right-0 bottom-0 h-px w-1/2 origin-right group-hover:scale-x-0 group-focus-visible:scale-x-0',\n 'top-0 left-0 h-1/2 w-px origin-top group-hover:scale-y-0 group-focus-visible:scale-y-0',\n 'bottom-0 left-0 h-1/2 w-px origin-bottom group-hover:scale-y-0 group-focus-visible:scale-y-0',\n 'top-0 right-0 h-1/2 w-px origin-top group-hover:scale-y-0 group-focus-visible:scale-y-0',\n 'right-0 bottom-0 h-1/2 w-px origin-bottom group-hover:scale-y-0 group-focus-visible:scale-y-0',\n];\n\nconst corner =\n 'pointer-events-none absolute size-2.5 border-[#045f64] transition-transform duration-500 ease-(--expo) group-hover:delay-200 group-hover:duration-700 group-hover:ease-(--snap) group-focus-visible:delay-200 group-focus-visible:duration-700 group-focus-visible:ease-(--snap) motion-reduce:transition-none dark:border-[#c6f56f]';\n\nconst corners = [\n 'top-0 left-0 border-t-[1.5px] border-l-[1.5px] group-hover:translate-1 group-focus-visible:translate-1',\n 'top-0 right-0 border-t-[1.5px] border-r-[1.5px] group-hover:-translate-x-1 group-hover:translate-y-1 group-focus-visible:-translate-x-1 group-focus-visible:translate-y-1',\n 'bottom-0 left-0 border-b-[1.5px] border-l-[1.5px] group-hover:translate-x-1 group-hover:-translate-y-1 group-focus-visible:translate-x-1 group-focus-visible:-translate-y-1',\n 'right-0 bottom-0 border-r-[1.5px] border-b-[1.5px] group-hover:-translate-1 group-focus-visible:-translate-1',\n];\n\nexport default function FocusFrameButton({\n children,\n className,\n type = 'button',\n ...props\n}: FocusFrameButtonProps) {\n return (\n \n {rails.map((placement) => (\n \n ))}\n {corners.map((placement) => (\n \n ))}\n \n {children}\n \n \n \n \n \n \n );\n}\n", + "codeHtml": "
import type { ComponentPropsWithRef } from 'react';\nimport { cn } from '@/lib/utils';\n\nexport interface FocusFrameButtonProps extends ComponentPropsWithRef<'button'> {\n  children: string;\n}\n\nconst roll =\n  'block transition-transform duration-600 ease-(--expo) group-hover:-translate-y-full group-focus-visible:-translate-y-full motion-reduce:transition-none';\n\nfunction RollLabel({ children }: { children: string }) {\n  return (\n    <span className=\"relative block h-5 overflow-hidden leading-5\">\n      <span className={roll}>{children}</span>\n      <span aria-hidden=\"true\" className={roll}>\n        {children}\n      </span>\n    </span>\n  );\n}\n\nfunction ReturnArrow({ className }: { className?: string }) {\n  return (\n    <svg\n      viewBox=\"0 0 20 20\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={1.6}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden=\"true\"\n      className={cn('size-4 flex-none', className)}\n    >\n      <path d=\"M3 3.5v4.57a4 4 0 0 0 4 4h10m-4.43-4.43L17 12.07l-4.43 4.43\" />\n    </svg>\n  );\n}\n\nconst slide =\n  '-translate-x-full transition-transform duration-600 ease-(--expo) group-hover:translate-x-0 group-focus-visible:translate-x-0 motion-reduce:transition-none';\n\nconst rail =\n  'pointer-events-none absolute bg-[#045f64]/35 transition-transform delay-200 duration-500 ease-(--expo) group-hover:delay-0 group-focus-visible:delay-0 motion-reduce:transition-none dark:bg-[#c6f56f]/35';\n\nconst rails = [\n  'top-0 left-0 h-px w-1/2 origin-left group-hover:scale-x-0 group-focus-visible:scale-x-0',\n  'top-0 right-0 h-px w-1/2 origin-right group-hover:scale-x-0 group-focus-visible:scale-x-0',\n  'bottom-0 left-0 h-px w-1/2 origin-left group-hover:scale-x-0 group-focus-visible:scale-x-0',\n  'right-0 bottom-0 h-px w-1/2 origin-right group-hover:scale-x-0 group-focus-visible:scale-x-0',\n  'top-0 left-0 h-1/2 w-px origin-top group-hover:scale-y-0 group-focus-visible:scale-y-0',\n  'bottom-0 left-0 h-1/2 w-px origin-bottom group-hover:scale-y-0 group-focus-visible:scale-y-0',\n  'top-0 right-0 h-1/2 w-px origin-top group-hover:scale-y-0 group-focus-visible:scale-y-0',\n  'right-0 bottom-0 h-1/2 w-px origin-bottom group-hover:scale-y-0 group-focus-visible:scale-y-0',\n];\n\nconst corner =\n  'pointer-events-none absolute size-2.5 border-[#045f64] transition-transform duration-500 ease-(--expo) group-hover:delay-200 group-hover:duration-700 group-hover:ease-(--snap) group-focus-visible:delay-200 group-focus-visible:duration-700 group-focus-visible:ease-(--snap) motion-reduce:transition-none dark:border-[#c6f56f]';\n\nconst corners = [\n  'top-0 left-0 border-t-[1.5px] border-l-[1.5px] group-hover:translate-1 group-focus-visible:translate-1',\n  'top-0 right-0 border-t-[1.5px] border-r-[1.5px] group-hover:-translate-x-1 group-hover:translate-y-1 group-focus-visible:-translate-x-1 group-focus-visible:translate-y-1',\n  'bottom-0 left-0 border-b-[1.5px] border-l-[1.5px] group-hover:translate-x-1 group-hover:-translate-y-1 group-focus-visible:translate-x-1 group-focus-visible:-translate-y-1',\n  'right-0 bottom-0 border-r-[1.5px] border-b-[1.5px] group-hover:-translate-1 group-focus-visible:-translate-1',\n];\n\nexport default function FocusFrameButton({\n  children,\n  className,\n  type = 'button',\n  ...props\n}: FocusFrameButtonProps) {\n  return (\n    <button\n      type={type}\n      className={cn(\n        'group relative inline-flex cursor-pointer p-1 text-sm font-medium tracking-wide whitespace-nowrap text-[#045f64] uppercase select-none [--expo:cubic-bezier(0.16,1,0.3,1)] [--snap:cubic-bezier(0.34,1.56,0.64,1)] focus-visible:outline-none disabled:pointer-events-none disabled:opacity-60',\n        className\n      )}\n      {...props}\n    >\n      {rails.map((placement) => (\n        <span key={placement} aria-hidden=\"true\" className={cn(rail, placement)} />\n      ))}\n      {corners.map((placement) => (\n        <span key={placement} aria-hidden=\"true\" className={cn(corner, placement)} />\n      ))}\n      <span className=\"relative flex h-11 items-center gap-2.5 overflow-hidden rounded-[2px] bg-[#c6f56f] px-5 transition-[scale] duration-200 ease-out group-active:scale-[0.97] motion-reduce:transition-none\">\n        <RollLabel>{children}</RollLabel>\n        <span aria-hidden=\"true\" className=\"relative flex size-4 overflow-hidden\">\n          <ReturnArrow className={slide} />\n          <ReturnArrow className={slide} />\n        </span>\n      </span>\n    </button>\n  );\n}\n
" + } + }, "logo-orbit": { "react": { "code": "'use client';\n\nimport { useRef, type ComponentPropsWithoutRef } from 'react';\nimport { useGSAP } from '@gsap/react';\nimport gsap from 'gsap';\nimport { cn } from '@/lib/utils';\n\ngsap.registerPlugin(useGSAP);\n\nconst prefersReducedMotion = () =>\n typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\nfunction LogoImage({ src, srcDark, alt }: LogoOrbitLogo) {\n if (!srcDark) {\n return {alt};\n }\n return (\n <>\n {alt}\n \n \n );\n}\n\nexport interface LogoOrbitLogo {\n src: string;\n /** Optional mark shown under `.dark`. */\n srcDark?: string;\n alt: string;\n}\n\nexport interface LogoOrbitProps extends ComponentPropsWithoutRef<'div'> {\n /** Logos placed evenly around the ring. */\n logos: LogoOrbitLogo[];\n /** Ring diameter in pixels. */\n size?: number;\n /** Rotation speed multiplier (1 = one turn every 20s). */\n speed?: number;\n /** Pause the orbit while the pointer is over the ring. */\n pauseOnHover?: boolean;\n}\n\nexport default function LogoOrbit({\n logos,\n size = 320,\n speed = 1,\n pauseOnHover = true,\n className,\n ...props\n}: LogoOrbitProps) {\n const ringRef = useRef(null);\n const hubRef = useRef(null);\n const pivotRefs = useRef<(HTMLDivElement | null)[]>([]);\n const itemRefs = useRef<(HTMLDivElement | null)[]>([]);\n\n useGSAP(\n () => {\n const ring = ringRef.current;\n const hub = hubRef.current;\n if (!ring || !hub) return;\n\n ring.style.setProperty('--orbit-size', `${size}px`);\n\n const hubSize = hub.offsetWidth || 20;\n const hubOffset = (size - hubSize) / 2;\n gsap.set(hub, { x: hubOffset, y: hubOffset, rotation: 0 });\n\n const pivots = pivotRefs.current.filter((el): el is HTMLDivElement => el !== null);\n const items = itemRefs.current.filter((el): el is HTMLDivElement => el !== null);\n const count = pivots.length;\n if (count === 0) return;\n\n // Distribute the logos evenly around the ring and keep each one upright.\n const space = 360 / count;\n const pivotOriginY = size / 2 + 10;\n pivots.forEach((pivot, i) => {\n const pivotHalf = pivot.offsetWidth / 2 || 10;\n gsap.set(pivot, {\n rotation: i * space,\n transformOrigin: `${pivotHalf}px ${pivotOriginY}px`,\n });\n });\n items.forEach((item, i) => {\n gsap.set(item, { rotation: -i * space, transformOrigin: 'center center' });\n });\n\n if (prefersReducedMotion()) return;\n\n const timeScale = Math.min(20, Math.max(0.05, speed));\n const tl = gsap.timeline({ repeat: -1 });\n tl.to(hub, { rotation: 360, duration: 20, ease: 'none' });\n tl.to(items, { rotation: '-=360', duration: 20, ease: 'none' }, 0);\n tl.timeScale(timeScale);\n\n if (!pauseOnHover) return;\n\n const pause = () => tl.pause();\n const resume = () => tl.resume();\n ring.addEventListener('pointerenter', pause);\n ring.addEventListener('pointerleave', resume);\n return () => {\n ring.removeEventListener('pointerenter', pause);\n ring.removeEventListener('pointerleave', resume);\n };\n },\n { scope: ringRef, dependencies: [size, speed, logos.length, pauseOnHover] }\n );\n\n return (\n \n
\n {logos.map((logo, i) => (\n {\n pivotRefs.current[i] = el;\n }}\n data-orbit-pivot\n className=\"absolute top-[calc(var(--orbit-size)*-0.5)]\"\n >\n {\n itemRefs.current[i] = el;\n }}\n className=\"absolute top-[-20px] grid size-16 place-items-center overflow-hidden rounded-full border border-[#045f64]/10 bg-white p-3 shadow-[0_1px_2px_rgba(16,24,40,0.12)] dark:border-[#045f64]/40 dark:bg-[#12161F]\"\n >\n \n
\n \n ))}\n \n \n );\n}\n", diff --git a/apps/docs/registry/demos/arrow-tile-button.tsx b/apps/docs/registry/demos/arrow-tile-button.tsx new file mode 100644 index 0000000..8d482d9 --- /dev/null +++ b/apps/docs/registry/demos/arrow-tile-button.tsx @@ -0,0 +1,10 @@ +import ArrowTileButton from '@/registry/tweenui/arrow-tile-button'; + +export default function ArrowTileButtonDemo() { + return ( +
+ Get started + Book a call +
+ ); +} diff --git a/apps/docs/registry/demos/focus-frame-button.tsx b/apps/docs/registry/demos/focus-frame-button.tsx new file mode 100644 index 0000000..f444abf --- /dev/null +++ b/apps/docs/registry/demos/focus-frame-button.tsx @@ -0,0 +1,5 @@ +import FocusFrameButton from '@/registry/tweenui/focus-frame-button'; + +export default function FocusFrameButtonDemo() { + return Talk to the team; +} diff --git a/apps/docs/registry/registry-ui.ts b/apps/docs/registry/registry-ui.ts index a2a9f7a..74d9f33 100644 --- a/apps/docs/registry/registry-ui.ts +++ b/apps/docs/registry/registry-ui.ts @@ -521,6 +521,72 @@ export default function Example() { export default function Example() { return {}}>Get started; +}`, + }, + }, + { + name: 'arrow-tile-button', + type: 'component', + addedAt: '2026-10-08', + title: 'Arrow Tile Button', + description: + 'A label slab paired with a detached arrow tile: the tilt variant has the tile knock the slab so its letters topple in a wave away from it and spring upright, and the swap variant rolls the tile to the front as the letters lift in its wake.', + media: {}, + dependencies: [], + registryDependencies: [], + cssVars: {}, + files: [ + { + path: 'registry/tweenui/arrow-tile-button.tsx', + target: 'components/tweenui/arrow-tile-button.tsx', + kind: 'react', + }, + ], + variants: [ + { + id: 'default', + label: 'Preview', + reactSource: 'registry/tweenui/arrow-tile-button.tsx', + }, + ], + usage: { + react: `import ArrowTileButton from '@/components/tweenui/arrow-tile-button'; + +export default function Example() { + return {}}>Get started; +}`, + }, + }, + { + name: 'focus-frame-button', + type: 'component', + addedAt: '2026-10-08', + title: 'Focus Frame Button', + description: + 'A framed button that locks on like a viewfinder on hover: the hairline folds into its corners, the brackets snap shut while the label rolls up and a fresh return arrow slides in.', + media: {}, + dependencies: [], + registryDependencies: [], + cssVars: {}, + files: [ + { + path: 'registry/tweenui/focus-frame-button.tsx', + target: 'components/tweenui/focus-frame-button.tsx', + kind: 'react', + }, + ], + variants: [ + { + id: 'default', + label: 'Preview', + reactSource: 'registry/tweenui/focus-frame-button.tsx', + }, + ], + usage: { + react: `import FocusFrameButton from '@/components/tweenui/focus-frame-button'; + +export default function Example() { + return {}}>Talk to the team; }`, }, }, diff --git a/apps/docs/registry/tweenui/arrow-tile-button.tsx b/apps/docs/registry/tweenui/arrow-tile-button.tsx new file mode 100644 index 0000000..d297157 --- /dev/null +++ b/apps/docs/registry/tweenui/arrow-tile-button.tsx @@ -0,0 +1,153 @@ +import type { ComponentPropsWithRef, CSSProperties } from 'react'; +import { cn } from '@/lib/utils'; + +export interface ArrowTileButtonProps extends ComponentPropsWithRef<'button'> { + children: string; + variant?: 'tilt' | 'swap'; +} + +function Arrow({ className }: { className?: string }) { + return ( + + ); +} + +interface Wave { + leadMs: number; + spreadMs: number; +} + +function waveDelay(index: number, count: number, { leadMs, spreadMs }: Wave): CSSProperties { + const step = count > 1 ? spreadMs / (count - 1) : 0; + const style: CSSProperties & { '--hop-delay': string } = { + '--hop-delay': `${leadMs + Math.round((count - 1 - index) * step)}ms`, + }; + return style; +} + +const toppleChar = + 'inline-block whitespace-pre transition-none group-[:is(:hover,:focus-visible)]:[translate:0_45%] group-[:is(:hover,:focus-visible)]:[rotate:-14deg] group-[:is(:hover,:focus-visible)]:[scale:1_0.35] group-[:is(:hover,:focus-visible)]:[transform:scale(1,2.8571)_rotate(14deg)_translateY(-45%)] group-[:is(:hover,:focus-visible)]:[transition:translate_145ms_var(--dip)_var(--hop-delay),rotate_145ms_var(--dip)_var(--hop-delay),scale_145ms_var(--dip)_var(--hop-delay),transform_580ms_var(--spring)_calc(var(--hop-delay)+145ms)] motion-reduce:transition-none'; + +const liftChar = + 'inline-block whitespace-pre transition-none group-[:is(:hover,:focus-visible)]:[translate:0_-40%] group-[:is(:hover,:focus-visible)]:[rotate:9deg] group-[:is(:hover,:focus-visible)]:[transform:rotate(-9deg)_translateY(40%)] group-[:is(:hover,:focus-visible)]:[transition:translate_160ms_var(--lift)_var(--hop-delay),rotate_160ms_var(--lift)_var(--hop-delay),transform_560ms_var(--spring)_calc(var(--hop-delay)+160ms)] motion-reduce:transition-none'; + +function WaveLabel({ + children, + charClassName, + wave, +}: { + children: string; + charClassName: string; + wave: Wave; +}) { + const chars = Array.from(children); + return ( + <> + {children} + + + ); +} + +const TOPPLE_WAVE: Wave = { leadMs: 140, spreadMs: 225 }; +const LIFT_WAVE: Wave = { leadMs: 40, spreadMs: 340 }; + +const knockRelease = + 'grid [transition:translate_150ms_var(--soft),rotate_150ms_var(--soft),scale_150ms_var(--soft),transform_150ms_var(--soft)] motion-reduce:transition-none'; + +function TiltContent({ children }: { children: string }) { + return ( + <> + + +