{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-fader",
  "type": "registry:ui",
  "title": "Fader",
  "description": "A channel-strip slider in both orientations: recessed groove, lit fill, and a grippy cap that jumps to wherever you press and keeps dragging from there. Wheel support, Shift for a fine pass, optional dB-style tick marks.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [
    "https://pluginui.com/r/core-cc-binding-editor.json",
    "https://pluginui.com/r/core-control-label.json",
    "https://pluginui.com/r/core-fader-headless.json"
  ],
  "files": [
    {
      "path": "components/ui/fader.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/fader\n * Copyright © [Maru](https://maru.audio)\n *\n * Maru UI Kit — https://maru.audio/ui-kit\n *\n * Installed from the Maru UI registry. This file is yours now: edit it\n * freely, it will not be overwritten unless you re-run the add command.\n *\n * Keep this item’s companion files and CSS imports together. Dependencies\n * declared by the registry are installed with the source.\n */\nimport {\n  controlCurveMapping,\n  type ControlCurve,\n} from \"maruaudio/ui/shared/knob-core\"\n/**\n * Fader — a channel-strip slider, vertical or horizontal.\n *\n * Recessed groove, lit fill up to the current value, and a grippy cap that\n * tracks the pointer 1:1. Pressing anywhere on the track jumps the cap to that\n * spot and keeps dragging from there — absolute positioning, the way a real\n * mixer behaves, rather than accumulating deltas from wherever you happened to\n * press. Optional tick marks along the side for dB-style gradations.\n *\n * Shift mid-gesture switches to a relative fine pass, so holding it doesn't\n * yank the cap back under the cursor.\n *\n * Six visual variants share all of that — a moulded `default`, a matte `flat`,\n * the long thin `channel` cap of a console, a cream `vintage`, a capless\n * `minimal` whose fill edge is the position, and an always-lit `neon`. They\n * are a table of traits rather than branches in the render, so the geometry,\n * the pointer mapping and the ARIA contract stay identical across all of them.\n *\n * The optional `midi` prop group makes it a MIDI CC drop target — drag a CC\n * chip onto the fader to bind it. Opt-in: without the prop nothing registers\n * and the markup is unchanged.\n *\n * The gesture itself — absolute positioning, the Shift fine pass, the wheel,\n * the keyboard and the `role=\"slider\"` contract — lives in `FaderHeadless`,\n * the same split `Knob` has from `KnobHeadless`. What is left here is looks,\n * the label and readout, double-click-to-default, and the CC layer.\n */\n\nimport { useId, useMemo, useRef } from \"react\"\nimport type { CSSProperties, ReactNode } from \"react\"\nimport { sanitize } from \"maruaudio/ui/shared/math\"\nimport {\n  accentFill,\n  capFace,\n  CONTROL_SKINS,\n  litGlow,\n  type ControlSkin,\n  type SkinTraits,\n} from \"maruaudio/ui/shared/control-skin\"\nimport { useControlSkin } from \"maruaudio/ui/shared/control-skin-context\"\nimport {\n  defaultFormat,\n  formatControlValueLabel,\n  type ValueFormatter,\n} from \"maruaudio/ui/shared/format\"\nimport { token } from \"maruaudio/ui/shared/theme\"\nimport {\n  ccActivityRingStyle,\n  type ControlMidiProps,\n} from \"maruaudio/ui/shared/cc-dnd\"\nimport { useControlledValue } from \"maruaudio/ui/shared/use-control\"\nimport {\n  useFirstInteraction,\n  type InteractionEvent,\n} from \"maruaudio/ui/shared/use-first-interaction\"\nimport { FaderHeadless } from \"@/components/ui/fader-headless\"\nimport {\n  ControlLabel,\n  FormattedControlValue,\n} from \"@/components/ui/control-label\"\n\nexport type FaderVariant =\n  \"default\" | \"flat\" | \"channel\" | \"vintage\" | \"minimal\" | \"neon\"\n\n/**\n * What a fader variant adds to a shared skin: the groove width, how long the\n * cap runs along the travel, and whether the cap is knurled.\n *\n * A capless skin (`cap: \"none\"`) still gets a cap-sized element here — it is\n * drawn as a hairline across the groove, because a fader with no marker at all\n * would leave the fill's edge doing a job it cannot do at the extremes.\n */\ninterface FaderVariantSpec extends SkinTraits {\n  /** Groove width as a fraction of the fader's thickness. */\n  grooveScale: number\n  /** Cap length along the travel, as a fraction of the thickness. */\n  capScale: number\n  /** Grip lines across the cap. */\n  grip: boolean\n}\n\n/** A shared skin plus this control's own geometry. */\nfunction faderSpec(\n  skin: SkinTraits,\n  geometry: Omit<FaderVariantSpec, keyof SkinTraits>\n): FaderVariantSpec {\n  return { ...skin, ...geometry }\n}\n\nconst FADER_VARIANTS: Record<FaderVariant, FaderVariantSpec> = {\n  default: faderSpec(CONTROL_SKINS.default, {\n    grooveScale: 0.26,\n    capScale: 0.55,\n    grip: true,\n  }),\n  // Matte software fader: no moulding, no grip lines, a flat accent fill.\n  flat: faderSpec(CONTROL_SKINS.flat, {\n    grooveScale: 0.2,\n    capScale: 0.5,\n    grip: false,\n  }),\n  // Console cap: long, thin and blade-like, riding a wider groove.\n  channel: faderSpec(CONTROL_SKINS.default, {\n    grooveScale: 0.34,\n    capScale: 0.34,\n    grip: false,\n  }),\n  // Cream cap with dark grips, the way a sixties board is knurled.\n  vintage: faderSpec(CONTROL_SKINS.vintage, {\n    grooveScale: 0.24,\n    capScale: 0.6,\n    grip: true,\n  }),\n  // No cap at all — the lit fill ends in a hairline that marks the value.\n  minimal: faderSpec(CONTROL_SKINS.minimal, {\n    grooveScale: 0.16,\n    capScale: 0.16,\n    grip: false,\n  }),\n  // Lit at rest, for a dark panel that should look powered up.\n  neon: faderSpec(CONTROL_SKINS.neon, {\n    grooveScale: 0.22,\n    capScale: 0.44,\n    grip: false,\n  }),\n}\n\n/** Normalized drawing state; capInset is also used by pointer mapping. */\nexport interface FaderDesignState {\n  value01: number\n  orientation: \"vertical\" | \"horizontal\"\n  length: number\n  thickness: number\n  capInset: number\n  active: boolean\n  disabled: boolean\n  formattedValue: string\n}\n\nexport interface FaderProps {\n  /** Optional author-supplied hover text; no default tooltip. */\n  title?: string\n  value?: number\n  defaultValue?: number\n  onChange?: (value: number) => void\n  onChangeEnd?: (value: number) => void\n\n  min?: number\n  max?: number\n  step?: number\n  curve?: ControlCurve\n\n  label?: string\n  /** Stable host parameter path. Resolved automatically inside a mapping provider. */\n  path?: string\n  unit?: string\n  /** Opt-in smaller unit typography; the formatted value remains unchanged. */\n  unitSize?: CSSProperties[\"fontSize\"]\n  format?: ValueFormatter\n  hideValue?: boolean\n  hideLabel?: boolean\n  renderDesign?: (state: FaderDesignState) => ReactNode\n  /** Total travel inset in pixels, half at each end of the track. */\n  capInset?: number\n\n  orientation?: \"vertical\" | \"horizontal\"\n  /**\n   * Visual preset — this control's own set, which adds `channel` to the shared\n   * skins. Looks only; every variant behaves identically. An explicit variant\n   * wins over an inherited skin.\n   */\n  variant?: FaderVariant\n  /**\n   * Finish, shared with every other control in the kit. Omitted, the fader\n   * takes whatever a surrounding `ControlSkinProvider` declares.\n   */\n  skin?: ControlSkin\n  /** Long axis in pixels. */\n  length?: number\n  /** Short axis in pixels — the width of the cap. */\n  thickness?: number\n  /**\n   * Tick marks drawn beside the groove. Pass a count for evenly spaced ticks,\n   * or explicit values in the control's own units for a dB scale.\n   */\n  ticks?: number | ReadonlyArray<number>\n\n  /** Normalised units per wheel notch. 0 disables the wheel. */\n  wheelSensitivity?: number\n  /**\n   * Fired once, on the first gesture of any kind, synchronously inside the\n   * event — the place to resume a suspended AudioContext.\n   */\n  onInteraction?: (event: InteractionEvent) => void\n\n  /**\n   * MIDI CC mapping. Present: the fader is a drop target for CC chips, wears\n   * its assignment as a badge and pulses on incoming traffic.\n   */\n  midi?: ControlMidiProps\n\n  disabled?: boolean\n  className?: string\n  style?: CSSProperties\n  \"aria-label\"?: string\n}\n\nexport function Fader({\n  title,\n  value,\n  defaultValue = 0,\n  onChange,\n  onChangeEnd,\n  min = 0,\n  max = 1,\n  step = 0,\n  curve = \"linear\",\n  label,\n  path,\n  unit = \"\",\n  unitSize,\n  format,\n  hideValue = false,\n  hideLabel = false,\n  renderDesign,\n  capInset,\n  orientation = \"vertical\",\n  variant,\n  skin,\n  length = 140,\n  thickness = 34,\n  ticks,\n  wheelSensitivity,\n  onInteraction,\n  midi,\n  disabled = false,\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n}: FaderProps) {\n  const range = { min, max, step }\n  const [current, commit, valueInteraction] = useControlledValue(\n    value,\n    defaultValue,\n    onChange,\n    range\n  )\n  const labelId = useId()\n  const vertical = orientation === \"vertical\"\n  // Explicit variant, else this control's own skin, else the panel's.\n  const inheritedSkin = useControlSkin(skin)\n  const spec =\n    FADER_VARIANTS[variant ?? inheritedSkin] ?? FADER_VARIANTS.default\n  const capless = spec.cap === \"none\"\n  // The cap length is geometry, not decoration: the usable travel is inset by\n  // half a cap, so the pointer mapping and the drawing must agree on it.\n  const capLength = thickness * spec.capScale\n  const travelInset = capInset ?? (capless ? 0 : capLength)\n  const latest = useRef(current)\n  latest.current = current\n\n  const formatter = useMemo(() => {\n    const source = format ?? defaultFormat(step > 0 ? step : 0.01, unit)\n    return (value: number) =>\n      formatControlValueLabel(source(value), unit, step > 0 ? step : 0.01)\n  }, [format, step, unit])\n\n  const tickValues = useMemo<ReadonlyArray<number>>(() => {\n    if (ticks === undefined) return []\n    if (Array.isArray(ticks)) return ticks\n    const count = ticks as number\n    if (count < 2) return []\n    return Array.from(\n      { length: count },\n      (_, i) => min + ((max - min) * i) / (count - 1)\n    )\n  }, [ticks, min, max])\n\n  const { notify } = useFirstInteraction(onInteraction)\n\n  const handleDoubleClick = () => {\n    if (disabled) return\n    const reset = sanitize(defaultValue, min, max, step)\n    commit(reset)\n    onChangeEnd?.(reset)\n  }\n\n  const { mapTo01, mapFrom01 } = useMemo(\n    () => controlCurveMapping(curve),\n    [curve]\n  )\n  const t = max === min ? 0 : mapTo01(current, min, max)\n  const grooveThickness = Math.max(4, thickness * spec.grooveScale)\n  const capThickness = thickness\n\n  const renderBody = (\n    control: ReactNode,\n    badge: ReactNode,\n    pulse: boolean | undefined\n  ): ReactNode => (\n    <>\n      {(!label || hideLabel) && hideValue ? (\n        control\n      ) : (\n        <ControlLabel\n          label={hideLabel ? undefined : label}\n          labelId={labelId}\n          value={\n            hideValue ? undefined : (\n              <FormattedControlValue\n                formatted={formatter(current)}\n                unitSize={unitSize}\n              />\n            )\n          }\n        >\n          {control}\n        </ControlLabel>\n      )}\n      {pulse !== undefined && (\n        <span aria-hidden style={ccActivityRingStyle(pulse)} />\n      )}\n      {badge && (\n        <span style={{ position: \"absolute\", top: -5, right: -7, zIndex: 1 }}>\n          {badge}\n        </span>\n      )}\n    </>\n  )\n  const hasCcIdentity = path !== undefined || midi !== undefined\n\n  const fader = (\n    <FaderHeadless\n      className={`uikit-control${className ? ` ${className}` : \"\"}`}\n      valueRaw={current}\n      valueMin={min}\n      valueMax={max}\n      step={step}\n      mapTo01={mapTo01}\n      mapFrom01={mapFrom01}\n      orientation={orientation}\n      // A capless face uses the whole track; every other face is positioned by\n      // its edge, so half a cap at each end is unusable travel.\n      capInset={travelInset}\n      wheelSensitivity={wheelSensitivity}\n      valueRawDisplayFn={formatter}\n      onValueRawChange={commit}\n      onGestureStart={valueInteraction.begin}\n      onGestureEnd={() => {\n        valueInteraction.end()\n        onChangeEnd?.(latest.current)\n      }}\n      onInteraction={notify}\n      disabled={disabled}\n      {...(hasCcIdentity\n        ? {\n            ...(path ? { path } : {}),\n            ...(midi ? { midi } : {}),\n            ccLabel: label ?? ariaLabel ?? path ?? \"Fader\",\n            ccRange: { min, max, ...(unit ? { unit } : {}) },\n            ccPresentation: {\n              label: label ?? ariaLabel ?? path ?? \"Fader\",\n              value: formatter(current),\n              ...(path ? { path } : {}),\n              range: `${min}–${max}${unit ? ` ${unit}` : \"\"}`,\n            },\n            renderCcBadge: false as const,\n            renderFrame: ({ control, chrome }) =>\n              renderBody(\n                control,\n                chrome.owns ? chrome.badge : null,\n                chrome.owns ? chrome.pulse : undefined\n              ),\n          }\n        : {})}\n      aria-label={\n        label && !hideLabel ? undefined : (ariaLabel ?? label ?? \"Fader\")\n      }\n      title={title}\n      aria-labelledby={label && !hideLabel ? labelId : undefined}\n      onDoubleClick={handleDoubleClick}\n      style={{\n        position: \"relative\",\n        width: vertical ? capThickness : length,\n        height: vertical ? length : capThickness,\n        cursor: disabled ? \"not-allowed\" : \"pointer\",\n        display: \"flex\",\n        alignItems: \"center\",\n        justifyContent: \"center\",\n        ...style,\n      }}\n    >\n      {({ dragging, value01 }) =>\n        renderDesign ? (\n          <span aria-hidden style={{ position: \"absolute\", inset: 0 }}>\n            {renderDesign({\n              value01,\n              orientation,\n              length,\n              thickness,\n              capInset: travelInset,\n              active: dragging,\n              disabled,\n              formattedValue: formatter(current),\n            })}\n          </span>\n        ) : (\n          <>\n            {/* Tick marks */}\n            {tickValues.map((tickValue) => {\n              const tt = max === min ? 0 : mapTo01(tickValue, min, max)\n              return (\n                <span\n                  key={tickValue}\n                  aria-hidden\n                  style={{\n                    position: \"absolute\",\n                    background: token.border,\n                    ...(vertical\n                      ? {\n                          right: 0,\n                          width: 6,\n                          height: 1,\n                          bottom: `calc(${capLength / 2}px + ${tt} * (100% - ${capLength}px))`,\n                        }\n                      : {\n                          top: 0,\n                          height: 6,\n                          width: 1,\n                          left: `calc(${capLength / 2}px + ${tt} * (100% - ${capLength}px))`,\n                        }),\n                  }}\n                />\n              )\n            })}\n\n            {/* Groove */}\n            <div\n              aria-hidden\n              style={{\n                position: \"absolute\",\n                borderRadius: 999,\n                background: token.track,\n                boxShadow: \"inset 0 1px 3px rgba(0,0,0,0.45)\",\n                ...(vertical\n                  ? { width: grooveThickness, top: 0, bottom: 0 }\n                  : { height: grooveThickness, left: 0, right: 0 }),\n              }}\n            />\n\n            {/* Lit fill */}\n            <div\n              aria-hidden\n              className={dragging ? undefined : \"uikit-animated\"}\n              style={{\n                position: \"absolute\",\n                borderRadius: 999,\n                background: accentFill(\n                  spec.solidFill,\n                  token.accent,\n                  token.accent2,\n                  vertical ? \"to top\" : \"to right\"\n                ),\n                boxShadow: litGlow(\n                  token.accent,\n                  grooveThickness,\n                  dragging,\n                  spec.glowAtRest\n                ),\n                ...(vertical\n                  ? {\n                      width: grooveThickness,\n                      bottom: 0,\n                      height: `calc(${capLength / 2}px + ${t} * (100% - ${capLength}px))`,\n                    }\n                  : {\n                      height: grooveThickness,\n                      left: 0,\n                      width: `calc(${capLength / 2}px + ${t} * (100% - ${capLength}px))`,\n                    }),\n              }}\n            />\n\n            {/* Cap. A capless skin marks the value with a lit hairline across the\n          groove instead; the others take the shared cap treatments. */}\n            <div\n              aria-hidden\n              className={dragging ? undefined : \"uikit-animated\"}\n              style={{\n                position: \"absolute\",\n                borderRadius: capless ? 999 : 4,\n                background:\n                  capFace(\n                    spec.cap,\n                    token.cap,\n                    vertical ? \"to bottom\" : \"to right\"\n                  ) ?? token.accent,\n                // Keep the groove hidden under translucent moulded highlights.\n                backgroundColor: capless ? token.accent : token.cap,\n                border: capless ? \"none\" : `1px solid ${token.border}`,\n                boxShadow: capless\n                  ? `0 0 6px ${token.accent}`\n                  : dragging\n                    ? `0 0 0 1px ${token.accent}, 0 4px 10px rgba(0,0,0,0.5)`\n                    : \"0 2px 6px rgba(0,0,0,0.45)\",\n                display: \"flex\",\n                alignItems: \"center\",\n                justifyContent: \"center\",\n                gap: 2,\n                flexDirection: vertical ? \"column\" : \"row\",\n                ...(vertical\n                  ? {\n                      width: capThickness,\n                      height: capLength,\n                      bottom: `calc(${t} * (100% - ${capLength}px))`,\n                    }\n                  : {\n                      height: capThickness,\n                      width: capLength,\n                      left: `calc(${t} * (100% - ${capLength}px))`,\n                    }),\n              }}\n            >\n              {/* Grip lines, plus one accent line marking the cap's read position.\n            A capless or ungripped variant marks the position with the cap's\n            own edge instead, so it draws nothing here. */}\n              {spec.grip && (\n                <>\n                  <span\n                    style={{\n                      background: spec.ink ?? token.accent,\n                      ...(vertical\n                        ? { width: \"60%\", height: 2, borderRadius: 1 }\n                        : { height: \"60%\", width: 2, borderRadius: 1 }),\n                    }}\n                  />\n                  {[0, 1].map((i) => (\n                    <span\n                      key={i}\n                      style={{\n                        background: spec.ink ?? token.border,\n                        opacity: 0.7,\n                        ...(vertical\n                          ? { width: \"45%\", height: 1 }\n                          : { height: \"45%\", width: 1 }),\n                      }}\n                    />\n                  ))}\n                </>\n              )}\n            </div>\n          </>\n        )\n      }\n    </FaderHeadless>\n  )\n\n  return hasCcIdentity ? fader : renderBody(fader, null, undefined)\n}\n",
      "type": "registry:component",
      "target": "@ui/fader.tsx"
    }
  ]
}
