{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "seek-button",
  "title": "Seek Button",
  "description": "A circular media skip control whose ring spins one full turn per press while the seconds it jumps stay upright at the centre.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://ui.ericts.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/base/ui/seek-button.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  animate,\n  motion,\n  useMotionValue,\n  type Transition,\n} from \"motion/react\";\n\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\n// The glyph is drawn in the same 36-unit box `play-button` uses, so a seek\n// button and a play button line up stroke-for-stroke in one transport bar.\nconst ICON_VIEW_BOX = 36;\nconst CENTER = ICON_VIEW_BOX / 2;\nconst RING_RADIUS = 13;\n/**\n * The ring stops short of a full turn; the gap is where the arrowhead leads.\n * Wide enough that the head and the ring's tail read as two ends of one arrow\n * rather than a stroke that merely thickens.\n */\nconst RING_GAP_DEGREES = 40;\n/** How far the head reaches back over the stroke, so the two read as one arrow. */\nconst ARROW_OVERLAP = 2.4;\nconst ARROW_LENGTH = 6.6;\nconst ARROW_HALF_HEIGHT = 3.7;\n\nfunction round(value: number) {\n  return Math.round(value * 100) / 100;\n}\n\n/** Degrees run clockwise from the top of the ring, which is where it ends. */\nfunction ringPoint(degrees: number) {\n  const radians = (degrees * Math.PI) / 180;\n\n  return [\n    round(CENTER + RING_RADIUS * Math.sin(radians)),\n    round(CENTER - RING_RADIUS * Math.cos(radians)),\n  ] as const;\n}\n\nconst [RING_START_X, RING_START_Y] = ringPoint(RING_GAP_DEGREES);\nconst RING_END_Y = CENTER - RING_RADIUS;\n// Sweeping clockwise past 180° means both arc flags are set.\nconst RING_PATH = `M ${RING_START_X} ${RING_START_Y} A ${RING_RADIUS} ${RING_RADIUS} 0 1 1 ${CENTER} ${RING_END_Y}`;\n\nconst ARROW_BASE_X = CENTER - ARROW_OVERLAP;\nconst ARROW_PATH = [\n  `M ${ARROW_BASE_X} ${round(RING_END_Y - ARROW_HALF_HEIGHT)}`,\n  `L ${round(ARROW_BASE_X + ARROW_LENGTH)} ${RING_END_Y}`,\n  `L ${ARROW_BASE_X} ${round(RING_END_Y + ARROW_HALF_HEIGHT)}`,\n  \"Z\",\n].join(\" \");\n\n// A press must read as exactly one turn, and the turn is the whole point of\n// the control, so it is paced to be watched rather than to get out of the way.\n// 400ms lands as the finger lifts: quick enough that a burst of taps stays one\n// continuous spin, slow enough to see the arrow come back round.\n//\n// The curve carries more of that budget than the duration does. It leaves\n// promptly, spends the middle of the ramp on the middle of the rotation, and\n// settles; a steep ease-out at the same duration would put two thirds of the\n// turn in the first few frames, which reads as a flicker rather than a turn.\nconst SPIN_TRANSITION: Transition = {\n  duration: 0.4,\n  ease: [0.45, 0.25, 0.2, 1],\n};\n\nconst TURN_DEGREES = 360;\n\n// Matches `play-button`: a 24px glyph inside a 40px surface, expressed as a\n// ratio so overriding `--seek-button-size` scales both.\nconst SURFACE_SIZE = 40;\nconst ICON_SIZE = 24;\nconst ICON_RATIO = `${(ICON_SIZE / SURFACE_SIZE) * 100}%`;\n\nconst AMOUNT_FONT_SIZE = 12;\n/** Three digits or more have to shrink to clear the ring's inner edge. */\nconst AMOUNT_FONT_SIZE_WIDE = 9;\n\nexport type SeekDirection = \"forward\" | \"backward\";\nexport type SeekSurface = \"ghost\" | \"glass\" | \"frosted\";\n\n/**\n * `glass` is the default because a skip control is read at a glance, usually\n * over artwork or video: it needs a hit target you can see before you reach for\n * it. All three are built from `currentColor` and theme tokens, so the glyph\n * stays readable on whatever sits behind them.\n *\n * The glass is a stack of layers rather than one translucent fill, because that\n * is what makes a pane read as a *solid* with thickness instead of a tinted\n * hole. From the back: the backdrop filter blurs and re-saturates the page\n * behind the disc; a scrim the colour of the page thins it out; a lens ring\n * blurs the rim harder than the middle, the way a thick edge bends what passes\n * through it; a fixed sheen lays a specular across the surface; and the shadow\n * stack lights the top edge, catches the bottom one, and drops the disc onto\n * the page.\n *\n * The scrim follows the environment the way an iOS material does, but it is not\n * the same colour in both themes, because a material lets light *through*: on a\n * light page it thins the backdrop towards the page (`background`), and on a\n * dark one it has to lift it instead (`foreground`). Scrimming a dark page with\n * more dark is what makes glass read as a smudge — nothing separates the disc\n * from the page, the black inner shade and drop shadow land invisible, and the\n * specular is left carrying the whole shape as a grey fog. Lifting instead puts\n * the pane in front of the page again, which is also why the sheen can come\n * down in the dark: it no longer has to do the separating on its own.\n *\n * The speculars stay light in both themes, since a highlight is a reflection of\n * the room rather than a colour of the surface. `frosted` pins the scrim to the\n * player's fixed on-video palette and keeps the rest.\n */\n/**\n * Five layers of light, written out in order so the stack can be read as one\n * thing: specular on the top edge, a catch-light on the bottom one, the pane's\n * own thickness shading its underside, then a contact shadow and a wide ambient\n * one. The ambient is deliberately broad and weak — a tight, strong drop shadow\n * rings the disc like a dark halo instead of lifting it off the page.\n *\n * These are achromatic on purpose and therefore literal: a highlight is white\n * and a shadow is black in every theme, so routing them through theme variables\n * would only add globals a consumer must not touch, and lose the whole stack\n * for anyone who copies this file by hand instead of installing it.\n */\nconst GLASS_SHADOW =\n  \"shadow-[inset_0_1px_1px_-0.5px_oklch(1_0_0_/_55%),inset_0_-1.5px_1px_-1px_oklch(1_0_0_/_30%),inset_0_-6px_12px_-8px_oklch(0_0_0_/_12%),0_2px_10px_-6px_oklch(0_0_0_/_7%),0_10px_40px_-16px_oklch(0_0_0_/_16%)]\";\n\nconst SURFACE_CLASSES: Record<SeekSurface, string> = {\n  ghost: \"hover:bg-muted hover:text-foreground dark:hover:bg-muted/50\",\n  glass: `bg-background/25 backdrop-blur-md backdrop-saturate-150 inset-ring-1 inset-ring-foreground/12 hover:bg-background/40 dark:bg-foreground/10 dark:inset-ring-foreground/20 dark:hover:bg-foreground/16 ${GLASS_SHADOW}`,\n  frosted: `bg-ericts-media-control/30 text-ericts-media-control-foreground backdrop-blur-[16px] backdrop-saturate-150 inset-ring-1 inset-ring-ericts-media-control-foreground/20 hover:bg-ericts-media-control/45 ${GLASS_SHADOW}`,\n};\n\n/**\n * Masks the lens ring to the rim: the middle of the disc keeps the surface's\n * own blur, and only the outer band gets the second pass.\n */\nconst LENS_MASK =\n  \"radial-gradient(closest-side, transparent 58%, currentColor 96%)\";\n\n/**\n * Two specular arcs, a bright one and a faint one, on opposite sides of the\n * disc. Off-axis (`from 140deg`) so neither lands square on the glyph.\n *\n * It does not move. The pane is bolted to the page, so the light on it is too —\n * only the glyph behind the glass turns. An earlier version drifted the sheen\n * with the spin at a fraction of its speed, for parallax; what it actually read\n * as was the whole button rotating, which is the one thing a fixed surface must\n * never look like it is doing.\n */\nconst SHEEN_GRADIENT =\n  \"conic-gradient(from 140deg, transparent 0deg, oklch(1 0 0 / 55%) 55deg, transparent 140deg, transparent 215deg, oklch(1 0 0 / 30%) 285deg, transparent 350deg)\";\n\n/**\n * Rotation the glyph should animate to for the next press. Each press adds a\n * whole turn to the previous *target* rather than restarting from zero, so a\n * burst of taps keeps spinning from wherever the glyph currently is.\n */\nexport function nextSeekRotation(rotation: number, direction: SeekDirection) {\n  return rotation + (direction === \"backward\" ? -TURN_DEGREES : TURN_DEGREES);\n}\n\nexport type SeekIconProps = Omit<React.ComponentProps<\"svg\">, \"children\"> & {\n  direction?: SeekDirection;\n};\n\n/** The default glyph: an open ring with an arrowhead at its leading end. */\nexport function SeekIcon({\n  direction = \"forward\",\n  className,\n  ...props\n}: SeekIconProps) {\n  return (\n    <svg\n      viewBox={`0 0 ${ICON_VIEW_BOX} ${ICON_VIEW_BOX}`}\n      fill=\"none\"\n      className={cn(\"stroke-current\", className)}\n      {...props}\n    >\n      {/* Backward is the same geometry mirrored, so the two directions cannot\n          drift apart as the ring is tuned. */}\n      <g\n        transform={\n          direction === \"backward\"\n            ? `translate(${ICON_VIEW_BOX} 0) scale(-1 1)`\n            : undefined\n        }\n      >\n        <path d={RING_PATH} strokeWidth={2.5} strokeLinecap=\"round\" />\n        <path d={ARROW_PATH} className=\"fill-current\" strokeWidth={0} />\n      </g>\n    </svg>\n  );\n}\n\nexport type SeekButtonProps = Omit<\n  React.ComponentProps<\"button\">,\n  \"children\"\n> & {\n  /** Which way the seek runs: the glyph, its spin, and `onSeek`'s sign all follow it. */\n  direction?: SeekDirection;\n  /** Seek amount in seconds, printed inside the ring and signed into `onSeek`. */\n  seconds?: number;\n  /** Print `seconds` inside the ring. Turn off when a custom icon carries its own. */\n  showSeconds?: boolean;\n  /** Replaces the default ring glyph. Whatever is passed spins on press. */\n  icon?: React.ReactNode;\n  /** Called with the signed offset in seconds: `+seconds` forward, `-seconds` back. */\n  onSeek?: (seconds: number) => void;\n  /** Diameter in px, also published as `--seek-button-size` for CSS overrides. */\n  size?: number;\n  /** `glass` is a translucent disc, `ghost` is bare, `frosted` is over-video glass. */\n  surface?: SeekSurface;\n  /** Transition for the one-turn spin. */\n  transition?: Transition;\n};\n\nexport function SeekButton({\n  direction = \"forward\",\n  seconds = 10,\n  showSeconds = true,\n  icon,\n  onSeek,\n  size = SURFACE_SIZE,\n  surface = \"glass\",\n  transition = SPIN_TRANSITION,\n  className,\n  style,\n  type = \"button\",\n  disabled,\n  onClick,\n  \"aria-label\": ariaLabel,\n  ...props\n}: SeekButtonProps) {\n  const shouldReduceMotion = useReducedMotion();\n  const rotation = useMotionValue(0);\n  // The target is tracked apart from the motion value: mid-spin the value sits\n  // between turns, and stacking the next press on it would cut the turn short.\n  const targetRotation = React.useRef(0);\n  const spinRef = React.useRef<ReturnType<typeof animate> | null>(null);\n\n  React.useEffect(() => () => spinRef.current?.stop(), []);\n\n  React.useEffect(() => {\n    if (!shouldReduceMotion) return;\n    spinRef.current?.stop();\n    spinRef.current = null;\n    targetRotation.current = 0;\n    rotation.set(0);\n  }, [shouldReduceMotion, rotation]);\n\n  const handleClick = React.useCallback(\n    (event: React.MouseEvent<HTMLButtonElement>) => {\n      onClick?.(event);\n\n      if (event.defaultPrevented || disabled) return;\n\n      if (!shouldReduceMotion) {\n        targetRotation.current = nextSeekRotation(\n          targetRotation.current,\n          direction,\n        );\n        spinRef.current?.stop();\n        spinRef.current = animate(rotation, targetRotation.current, transition);\n      }\n\n      onSeek?.(direction === \"backward\" ? -seconds : seconds);\n    },\n    [\n      direction,\n      disabled,\n      onClick,\n      onSeek,\n      rotation,\n      seconds,\n      shouldReduceMotion,\n      transition,\n    ],\n  );\n\n  const amount = String(seconds);\n  const glass = surface !== \"ghost\";\n\n  return (\n    <button\n      type={type}\n      disabled={disabled}\n      onClick={handleClick}\n      aria-label={\n        ariaLabel ??\n        `${direction === \"backward\" ? \"Back\" : \"Forward\"} ${amount} seconds`\n      }\n      data-slot=\"seek-button\"\n      data-direction={direction}\n      style={{\n        [\"--seek-button-size\" as string]: `${size}px`,\n        width: \"var(--seek-button-size)\",\n        height: \"var(--seek-button-size)\",\n        ...style,\n      }}\n      className={cn(\n        \"relative inline-grid shrink-0 place-items-center rounded-full outline-none\",\n        // Hover tint and press scale share one short ease-out, so the button\n        // answers the pointer before the spin has visibly started.\n        \"transition-[background-color,transform] duration-100 ease-out\",\n        \"active:scale-[0.94] disabled:pointer-events-none disabled:opacity-50\",\n        \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current\",\n        \"motion-reduce:transition-none motion-reduce:active:scale-100\",\n        SURFACE_CLASSES[surface],\n        className,\n      )}\n      {...props}\n    >\n      {glass ? (\n        <>\n          <span\n            aria-hidden\n            data-slot=\"seek-button-lens\"\n            className=\"pointer-events-none absolute inset-0 rounded-full backdrop-blur-[5px]\"\n            style={{ maskImage: LENS_MASK, WebkitMaskImage: LENS_MASK }}\n          />\n          <span\n            aria-hidden\n            data-slot=\"seek-button-sheen\"\n            className=\"pointer-events-none absolute inset-0 rounded-full opacity-55 dark:opacity-40\"\n            style={{ backgroundImage: SHEEN_GRADIENT }}\n          />\n        </>\n      ) : null}\n      <motion.span\n        aria-hidden\n        data-slot=\"seek-button-icon\"\n        className=\"col-start-1 row-start-1 grid place-items-center [&>svg]:size-full\"\n        style={{\n          width: ICON_RATIO,\n          height: ICON_RATIO,\n          rotate: rotation,\n        }}\n      >\n        {icon ?? <SeekIcon direction={direction} />}\n      </motion.span>\n      {showSeconds ? (\n        // Outside the spinning element on purpose: the ring turns, the number\n        // it is turning by stays upright and readable the whole way round.\n        <svg\n          aria-hidden\n          data-slot=\"seek-button-seconds\"\n          viewBox={`0 0 ${ICON_VIEW_BOX} ${ICON_VIEW_BOX}`}\n          className=\"pointer-events-none col-start-1 row-start-1 fill-current font-semibold tabular-nums\"\n          style={{ width: ICON_RATIO, height: ICON_RATIO }}\n        >\n          <text\n            x={CENTER}\n            y={CENTER}\n            textAnchor=\"middle\"\n            dominantBaseline=\"central\"\n            fontSize={\n              amount.length > 2 ? AMOUNT_FONT_SIZE_WIDE : AMOUNT_FONT_SIZE\n            }\n          >\n            {amount}\n          </text>\n        </svg>\n      ) : null}\n    </button>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@ui/seek-button.tsx"
    }
  ],
  "cssVars": {
    "light": {
      "ericts-media-control": "oklch(0 0 0)",
      "ericts-media-control-foreground": "oklch(1 0 0)"
    },
    "dark": {
      "ericts-media-control": "oklch(0 0 0)",
      "ericts-media-control-foreground": "oklch(1 0 0)"
    }
  },
  "meta": {
    "tags": [
      "button",
      "seek",
      "skip",
      "rewind",
      "media",
      "video",
      "custom-icon"
    ],
    "effects": [
      "rotation",
      "svg-animation",
      "press-scale",
      "frosted-glass",
      "reduced-motion"
    ]
  },
  "categories": [
    "action"
  ],
  "type": "registry:ui"
}