跳至正文
组件

Angle Dial角度刻度尺

方条样式的角度输入:左右拖动刻度、中间竖线指针读数,满一圈首尾相接,与数值滑块同一套高度。

正在加载…

安装

pnpm dlx shadcn@latest add https://yiui.dev/r/angle-dial.json
会连带安装 本站条目 grid-layout、flow-diagram。已存在的文件会询问是否覆盖。

用法

components/straighten-dial.tsx
import { useState } from "react"
import { AngleDial } from "@/components/ui/angle-dial/angle-dial"

export function StraightenDial() {
  const [angle, setAngle] = useState(0)

  return (
    <AngleDial
      label="拉直"
      min={-45}
      max={45}
      value={angle}
      onValueChange={setAngle}
    />
  )
}

value / defaultValue / onValueChange 与站内其他方条控件同一套约定:给 value 就是受控,不给就由组件自己管,defaultValue 只决定起点与复位目标。外框、圆角与高度跟数值滑块完全一致,同一个面板里并排摆不会错位。

拖动刻度,读正中指针

刻度尺里躺着的是一段转盘,固定在正中的竖线是读数指针。按住刻度往左拖,刻度跟着手走,指针就读到更大的角度——每移动 pixelsPerDegree 像素走一度,默认是 3;调大更细,调小更灵敏。刻度每 5° 一根短线、每 15° 一根长线,两端渐隐,像从窗口里露出的一段转盘。

右侧是格式化后的读数,以及一个跟着转的小表盘,扫一眼就知道现在偏向哪边。

键盘与复位

刻度尺自己可以聚焦,键盘操作与数值滑块一致:

按键变化
← ↓减一个 step
→ ↑加一个 step
Shift + 方向键按 largeStep(默认 15)
PageUp / PageDown按 largeStep
Home / End到两端

双击刻度尺回到 defaultValue;不传 defaultValue 时回到 0,0 不在范围内时取 min。拖动松手、每次键盘变更、双击复位之后都会触发 onValueCommitted,需要「松手才算一次」时用它。

首尾相接

max - min 正好是 360 时自动首尾相接:范围是一整圈,越过 max 会从 min 接着走,360° 与 0° 是同一个角度,所以读数显示成 min。首尾相接时按 End 会停在 max - step(即离一圈终点最近的那一档),不会停在和起点重合的位置。

给别的范围也可以手动打开:传 wrap 即可,wrap={false} 则关掉自动判断。

<AngleDial label="朝向" min={0} max={360} defaultValue={90} />

高度、色调与「已改动」

variant 与 size 的组合高度和数值滑块同一张表:

variantsmdefaultlg
bar28px32px36px
compact18px22px24px

tone 只给指针与小表盘上色,取 --flow-tone;不传时用前景色。modified 把标签与读数从次要色提到前景色,并在根元素上留一个 data-modified,通常写成 modified={value !== defaultValue},让控制面板里改过的项一眼可辨。

<AngleDial
  label="旋转"
  variant="compact"
  tone="amber"
  modified={rotation !== 0}
  value={rotation}
  onValueChange={setRotation}
/>

示例

罗盘方位

范围 0–360 正好一圈,wrap 自动打开:拖过 360° 会从 0° 接着走。罗盘指针与刻度尺右侧的小表盘同步转动,读数旁标出八方位名。largeStep={45} 让 Shift + 方向键一次跳一个方位。

正在加载…

渐变角度

色块的 linear-gradient 角度直接来自刻度,下面一行是这段 CSS 的简写。渐变色只用主题提供的 --flow-* 变量,亮暗与颜色主题切换时一起变。

正在加载…

API

AngleDial

默认 min={-180}、max={180}、step={1}、largeStep={15}、pixelsPerDegree={3}、variant="bar"、size="default";wrap 不传时按「范围是否正好 360°」自动决定,formatValue 默认是 数值°,modified、disabled 默认关闭,tone 不传时用前景色。除上表外,其余属性直接落在根元素 <div> 上。

angleDialVariants

cva 生成的根元素样式函数,有 size 与 variant 两个变体。高度通过根元素的 --angle-dial-height 变量传给内部,自己写包装层时可以复用它保持同一套高度。

const angleDialVariants: (props?: {
  size?: AngleDialSize
  variant?: AngleDialVariant
}) => string

类型

type AngleDialSize = "sm" | "default" | "lg"

type AngleDialVariant = "bar" | "compact"

type AngleDialProps = Omit<
  React.ComponentProps<"div">,
  "defaultValue" | "onChange" | "children"
> & {
  value?: number
  defaultValue?: number
  onValueChange?: (value: number) => void
  onValueCommitted?: (value: number) => void
  min?: number
  max?: number
  step?: number
  largeStep?: number
  wrap?: boolean
  pixelsPerDegree?: number
  label?: React.ReactNode
  formatValue?: (value: number) => string
  variant?: AngleDialVariant
  size?: AngleDialSize
  tone?: FlowTone
  modified?: boolean
  disabled?: boolean
}

data-slot

元素data-slot
根元素angle-dial
左侧标签angle-dial-label
刻度尺angle-dial-ruler
刻度angle-dial-ticks
指针angle-dial-needle
读数与表盘angle-dial-value
小表盘angle-dial-glyph

根元素始终带 data-variant(bar / compact);拖动中带 data-dragging,modified 为真时带 data-modified,禁用时带 data-disabled。

可访问性

  • 刻度尺是 role="slider",带 aria-valuemin / aria-valuemax / aria-valuenow 与 aria-valuetext。aria-valuetext 用的就是 formatValue 的结果,所以把读数换成「东北」「+12°」这类写法后,读屏念的也是它。
  • 名称优先取 label(aria-labelledby),没有 label 时用 aria-label,两者都没有就退回「角度」——同一个表单里有多个刻度尺时务必给名称。
  • 刻度、指针、读数与表盘都是 aria-hidden 的装饰,读屏只念上面那几个值。
  • 焦点环只在键盘聚焦时出现(focus-visible)。鼠标按下刻度尺不会留下焦点框,但焦点会落到刻度尺上,可以用方向键接着微调。
  • disabled 时去掉 Tab 可达性(tabIndex={-1})、指针交互与键盘响应,并降到半透明。
  • 系统开启「减少动态效果」时指针不再在拖动中轻微放大。

注意事项

  • 组件在服务端就输出完整结构(刻度按当前值平移、表盘指针带着 rotate()),不需要预水合脚本,禁用 JS 时也能看到当前值。
  • formatValue、defaultValue 建议定义在组件外,避免每次渲染都换一个新函数或新对象。
  • 拖动期间的读数变化会正常触发 React 渲染;一次拖动里读数会连续刷新,这是有意的。
  • 用作表单字段时外面自己套 input type="hidden",组件只提交到 onValueChange / onValueCommitted。
  • 受控模式下记得同时处理 onValueChange,否则拖动后值会弹回。
  • tone 的颜色变量由 flow-diagram 条目提供,安装时会一并拉取;手动安装需要自己补上 --flow-* 变量。

源码

components/ui/angle-dial/angle-dial.tsx
"use client"

import * as React from "react"
import { cva } from "class-variance-authority"

import { cn } from "@/lib/utils"
import { useControllableState } from "@/hooks/use-controllable-state"
import { flowToneStyle, type FlowTone } from "@/components/ui/flow-diagram/tone"

/**
 * 角度刻度尺:方条中间是一段可以左右拖动的刻度,固定在正中的竖线指针读出当前角度。
 * 外框与高度表与数值滑块一致(bar 28 / 32 / 36px,compact 18 / 22 / 24px),放进同一个面板能对齐。
 */
const angleDialVariants = cva(
  "group/angle-dial relative flex h-(--angle-dial-height) w-full min-w-0 items-center gap-3 overflow-hidden rounded-md border border-edge bg-muted/60 px-3 select-none",
  {
    variants: {
      size: {
        sm: "text-xs",
        default: "text-sm",
        lg: "text-sm",
      },
      variant: {
        bar: "",
        compact: "gap-2 px-2.5 text-xs",
      },
    },
    compoundVariants: [
      { variant: "bar", size: "sm", class: "[--angle-dial-height:1.75rem]" },
      { variant: "bar", size: "default", class: "[--angle-dial-height:2rem]" },
      { variant: "bar", size: "lg", class: "[--angle-dial-height:2.25rem]" },
      {
        variant: "compact",
        size: "sm",
        class: "[--angle-dial-height:1.125rem]",
      },
      {
        variant: "compact",
        size: "default",
        class: "[--angle-dial-height:1.375rem]",
      },
      { variant: "compact", size: "lg", class: "[--angle-dial-height:1.5rem]" },
    ],
    defaultVariants: {
      size: "default",
      variant: "bar",
    },
  },
)

type AngleDialSize = "sm" | "default" | "lg"
type AngleDialVariant = "bar" | "compact"

type AngleDialProps = Omit<
  React.ComponentProps<"div">,
  "defaultValue" | "onChange" | "children"
> & {
  /** 受控值(度) */
  value?: number
  /** 非受控时的初始值;双击刻度尺时也回到它。不传时取 0(不在范围内则取 `min`) */
  defaultValue?: number
  onValueChange?: (value: number) => void
  /** 拖动松手、键盘每次变更、双击复位后触发 */
  onValueCommitted?: (value: number) => void
  /**
   * 最小值
   * @default -180
   */
  min?: number
  /**
   * 最大值
   * @default 180
   */
  max?: number
  /**
   * 步长
   * @default 1
   */
  step?: number
  /**
   * PageUp / PageDown 与 Shift + 方向键的步长
   * @default 15
   */
  largeStep?: number
  /**
   * 首尾相接:越过一端从另一端接着走。不传时,范围正好一整圈(360°)就自动开启
   */
  wrap?: boolean
  /**
   * 拖动灵敏度:每度对应的像素数,越大越细
   * @default 3
   */
  pixelsPerDegree?: number
  /** 左侧标签,同时作为可访问名称 */
  label?: React.ReactNode
  /** 读数格式;默认 `数值°`,读屏朗读的也是它 */
  formatValue?: (value: number) => string
  /**
   * 高度档:`bar` 与数值滑块默认同高,`compact` 是面板里用的中等高度
   * @default "bar"
   */
  variant?: AngleDialVariant
  /**
   * 尺寸档
   * @default "default"
   */
  size?: AngleDialSize
  /** 色调:指针与小表盘取 `--flow-tone`;不传时用前景色 */
  tone?: FlowTone
  /**
   * 标记为「已改动」:标签与读数改用前景色,根元素带 `data-modified`
   * @default false
   */
  modified?: boolean
  disabled?: boolean
}

/** 小刻度的间隔(度)与每隔几个小刻度画一根大刻度 */
const MINOR = 5
const MAJOR_EVERY = 3

const defaultFormat = (value: number) => `${value}°`

/** 把值约束进范围:首尾相接时取模,否则夹住;再对齐到步长 */
function normalize(
  value: number,
  {
    min,
    max,
    step,
    wrap,
  }: { min: number; max: number; step: number; wrap: boolean },
) {
  let next = value
  if (wrap) {
    const span = max - min
    next = ((((next - min) % span) + span) % span) + min
  } else {
    next = Math.min(max, Math.max(min, next))
  }
  const decimals = (String(step).split(".")[1] ?? "").length
  const stepped = min + Math.round((next - min) / step) * step
  // 首尾相接时 max 与 min 是同一个角度,统一显示成 min
  const result = wrap && stepped >= max ? min : stepped
  return Number(result.toFixed(decimals))
}

/**
 * 角度刻度尺。拖动刻度(不是拖指针)改变角度:手指往左带走刻度,指针就读到更大的角度。
 * 键盘:方向键按 `step`、Shift / PageUp / PageDown 按 `largeStep`、Home / End 到两端;双击复位。
 */
function AngleDial({
  value: valueProp,
  defaultValue,
  onValueChange,
  onValueCommitted,
  min = -180,
  max = 180,
  step = 1,
  largeStep = 15,
  wrap: wrapProp,
  pixelsPerDegree = 3,
  label,
  formatValue = defaultFormat,
  variant = "bar",
  size = "default",
  tone,
  modified = false,
  disabled = false,
  className,
  style,
  "aria-label": ariaLabel,
  ...props
}: AngleDialProps) {
  const wrap = wrapProp ?? max - min === 360
  const options = { min, max, step, wrap }
  const resetValue = defaultValue ?? (0 >= min && 0 <= max ? 0 : min)
  const [value, setValue] = useControllableState<number>({
    prop: valueProp,
    defaultProp: resetValue,
    onChange: onValueChange,
  })
  const labelId = React.useId()
  const [dragging, setDragging] = React.useState(false)
  const dragRef = React.useRef<{ x: number; from: number } | null>(null)

  const commit = (next: number) => {
    const normalized = normalize(next, options)
    if (normalized !== value) setValue(normalized)
    return normalized
  }

  const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
    if (disabled || event.button !== 0) return
    // 鼠标按下不抢走文字选区之外的焦点样式;触摸与笔照常
    if (event.pointerType === "mouse") event.preventDefault()
    event.currentTarget.focus()
    event.currentTarget.setPointerCapture(event.pointerId)
    dragRef.current = { x: event.clientX, from: value }
    setDragging(true)
  }

  const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
    const drag = dragRef.current
    if (!drag) return
    commit(drag.from - (event.clientX - drag.x) / pixelsPerDegree)
  }

  const endDrag = (event: React.PointerEvent<HTMLDivElement>) => {
    if (!dragRef.current) return
    dragRef.current = null
    setDragging(false)
    if (event.currentTarget.hasPointerCapture(event.pointerId)) {
      event.currentTarget.releasePointerCapture(event.pointerId)
    }
    onValueCommitted?.(value)
  }

  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    if (disabled) return
    const big = event.shiftKey ? largeStep : step
    let next: number | undefined
    if (event.key === "ArrowRight" || event.key === "ArrowUp")
      next = value + big
    else if (event.key === "ArrowLeft" || event.key === "ArrowDown")
      next = value - big
    else if (event.key === "PageUp") next = value + largeStep
    else if (event.key === "PageDown") next = value - largeStep
    else if (event.key === "Home") next = min
    else if (event.key === "End") next = wrap ? max - step : max
    if (next === undefined) return
    event.preventDefault()
    // 先算再回调:写成 onValueCommitted?.(commit(next)) 时,没传回调会连 commit 一起短路掉
    const committed = commit(next)
    onValueCommitted?.(committed)
  }

  const handleDoubleClick = () => {
    if (disabled) return
    const committed = commit(resetValue)
    onValueCommitted?.(committed)
  }

  const readout = formatValue(value)
  // 刻度跟着值平移:值每增加 1°,刻度往左移 pixelsPerDegree 像素
  const minorGap = MINOR * pixelsPerDegree
  const offset = `calc(50% - ${(value * pixelsPerDegree).toFixed(2)}px)`

  return (
    <div
      {...props}
      data-slot="angle-dial"
      data-variant={variant}
      data-dragging={dragging ? "" : undefined}
      data-modified={modified ? "" : undefined}
      data-disabled={disabled ? "" : undefined}
      className={cn(
        angleDialVariants({ size, variant }),
        "data-[disabled]:opacity-50",
        className,
      )}
      style={{ ...flowToneStyle(tone), ...style }}
    >
      {label !== undefined ? (
        <span
          id={labelId}
          data-slot="angle-dial-label"
          className="shrink-0 truncate font-medium text-muted-foreground transition-colors group-data-[modified]/angle-dial:text-foreground"
        >
          {label}
        </span>
      ) : null}
      <div
        role="slider"
        tabIndex={disabled ? -1 : 0}
        aria-labelledby={label !== undefined ? labelId : undefined}
        aria-label={label === undefined ? (ariaLabel ?? "角度") : undefined}
        aria-valuemin={min}
        aria-valuemax={max}
        aria-valuenow={value}
        aria-valuetext={readout}
        aria-disabled={disabled || undefined}
        data-slot="angle-dial-ruler"
        onPointerDown={handlePointerDown}
        onPointerMove={handlePointerMove}
        onPointerUp={endDrag}
        onPointerCancel={endDrag}
        onKeyDown={handleKeyDown}
        onDoubleClick={handleDoubleClick}
        className="relative h-full min-w-0 flex-1 cursor-ew-resize touch-none rounded-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/50 in-data-[disabled]:cursor-not-allowed"
      >
        {/* 刻度:两层重复渐变,小刻度每 5°、大刻度每 15°;两端渐隐,像一段从窗口里露出来的转盘 */}
        <span
          aria-hidden="true"
          data-slot="angle-dial-ticks"
          className="pointer-events-none absolute inset-0 [mask-image:linear-gradient(to_right,transparent,var(--foreground)_22%,var(--foreground)_78%,transparent)]"
          style={{
            backgroundImage: [
              "linear-gradient(to right, color-mix(in oklab, var(--foreground) 45%, transparent) 1px, transparent 1px)",
              "linear-gradient(to right, color-mix(in oklab, var(--foreground) 22%, transparent) 1px, transparent 1px)",
            ].join(", "),
            backgroundSize: `${minorGap * MAJOR_EVERY}px 60%, ${minorGap}px 34%`,
            backgroundPosition: `${offset} center, ${offset} center`,
            backgroundRepeat: "repeat-x",
          }}
        />
        {/* 指针:固定在正中的竖线,与数值滑块的手柄同形 */}
        <span
          aria-hidden="true"
          data-slot="angle-dial-needle"
          className={cn(
            "pointer-events-none absolute inset-y-[18%] left-1/2 w-1 -translate-x-1/2 rounded-full transition-transform group-data-[dragging]/angle-dial:scale-y-110 motion-reduce:transition-none",
            tone !== undefined ? "bg-(--flow-tone)" : "bg-foreground",
          )}
        />
      </div>
      <span
        aria-hidden="true"
        data-slot="angle-dial-value"
        className="flex shrink-0 items-center gap-1.5 font-mono text-muted-foreground tabular-nums transition-colors group-hover/angle-dial:text-foreground group-data-[dragging]/angle-dial:text-foreground group-data-[modified]/angle-dial:text-foreground"
      >
        {readout}
        {/* 小表盘:指针随角度转,帮助读出方向 */}
        <svg
          viewBox="0 0 16 16"
          data-slot="angle-dial-glyph"
          className="size-3.5 shrink-0"
        >
          <circle
            cx="8"
            cy="8"
            r="6.5"
            fill="none"
            stroke="currentColor"
            strokeOpacity="0.4"
          />
          <line
            x1="8"
            y1="8"
            x2="8"
            y2="2.5"
            strokeWidth="1.6"
            strokeLinecap="round"
            className={
              tone !== undefined ? "stroke-(--flow-tone)" : "stroke-current"
            }
            transform={`rotate(${value.toFixed(2)} 8 8)`}
          />
        </svg>
      </span>
    </div>
  )
}

export { AngleDial, angleDialVariants }
export type { AngleDialProps, AngleDialSize, AngleDialVariant }
hooks/use-controllable-state.ts
"use client"

import * as React from "react"

type UseControllableStateParams<T> = {
  /** 受控值;传入后组件不再维护内部状态 */
  prop?: T
  /** 非受控时的初始值 */
  defaultProp: T
  /** 值变化时回调(受控与非受控都会触发) */
  onChange?: (value: T) => void
}

/**
 * 同时支持受控与非受控的状态 hook。
 * 约定见 component-conventions 技能第 4 节:open/defaultOpen/onOpenChange 等成对 props 都用它实现。
 */
function useControllableState<T>({
  prop,
  defaultProp,
  onChange,
}: UseControllableStateParams<T>) {
  const [uncontrolled, setUncontrolled] = React.useState(defaultProp)
  const isControlled = prop !== undefined
  const value = isControlled ? prop : uncontrolled

  // 用 ref 保存最新的回调和值,保证 setValue 引用稳定
  const onChangeRef = React.useRef(onChange)
  const valueRef = React.useRef(value)
  React.useEffect(() => {
    onChangeRef.current = onChange
    valueRef.current = value
  })

  const setValue = React.useCallback(
    (next: React.SetStateAction<T>) => {
      const resolved =
        typeof next === "function"
          ? (next as (prev: T) => T)(valueRef.current)
          : next
      if (Object.is(resolved, valueRef.current)) return
      valueRef.current = resolved
      if (!isControlled) setUncontrolled(resolved)
      onChangeRef.current?.(resolved)
    },
    [isControlled],
  )

  return [value, setValue] as const
}

export { useControllableState }