Bevel

Gauge and dial

A needle on a brass-bezelled scale. Gauge reads a value; Dial sets one, turned by drag or keyboard.

Runway

Days of credit left, with the auto top-up level marked.

Runwayx

Amber mark: auto top-up at 5 days

Auto top-up

Set like a thermostat: the level to keep above, and how much to add.

Keep balance above
Top up by

When the balance drops below $30, we add $10.

Built from

Gauge and dial is made of components that also work on their own.

Installation

Add with the CLI

  1. Once per project, add the Bevel registry to components.json.

    components.json
    {
      "registries": {
        "@bevel": "https://bevel-ui.vercel.app/r/{name}.json"
      }
    }
  2. Add Gauge and dial. This brings in Amount and the Bevel foundation, installs @base-ui/react and cn, and merges the styles into your global CSS.

    Terminal
    npx shadcn@latest add @bevel/dial
Install manually
  1. Install the dependencies.

    Terminal
    pnpm add @base-ui/react cn
  2. Add the Bevel foundation, if you haven't already. Light, depth, materials, motion and shape tokens, plus the utilities components use to apply them.

    styles/bevel.css
    /*
     * Bevel foundation. Every component builds on these tokens and utilities;
     * component stylesheets only add their own palettes and shapes.
     *
     * Layers:
     *   1. Light     one source, above and slightly in front of the screen.
     *   2. Depth     named elevations built from the light: recessed, flush,
     *                raised, pressed, object.
     *   3. Material  a surface recipe (leather, plastic, metal) that reads
     *                --bevel-material and --bevel-material-edge.
     *   4. Motion    two curves, a spring, and durations named by purpose.
     *   5. Shape     radii, stitching, and the type scale for amounts.
     */
    
    :root {
      /* 1. Light */
      --bevel-highlight: oklch(1 0 0 / 0.22);
      --bevel-highlight-strong: oklch(1 0 0 / 0.35);
      --bevel-shade: oklch(0 0 0 / 0.32);
      --bevel-shade-strong: oklch(0 0 0 / 0.5);
      --bevel-cast: oklch(0.25 0.03 50 / 0.22);
      /* Stamped into a surface: shaded on the upper inner edge, lit below. */
      --bevel-deboss: 0 -1px 0 oklch(0 0 0 / 0.45), 0 1px 0 oklch(1 0 0 / 0.12);
    
      /* 3. Material defaults; components and variants override these. */
      --bevel-material: oklch(0.47 0.085 52);
      --bevel-material-edge: oklch(0.3 0.06 44);
      --bevel-material-recess: oklch(0.2 0.008 260); /* neutral dark; materials set their own */
      --bevel-grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.7' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .7 0 0 0 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)'/%3E%3C/svg%3E");
      --bevel-grain-size: 180px;
    
      /* 2. Depth */
      --bevel-depth-recessed:
        inset 0 1px 2px var(--bevel-shade-strong),
        0 1px 0 var(--bevel-highlight);
      --bevel-depth-flush:
        inset 0 1px 0 var(--bevel-highlight),
        inset 0 -1px 0 var(--bevel-shade);
      --bevel-depth-raised:
        inset 0 1px 0 var(--bevel-highlight),
        0 1px 0 var(--bevel-material-edge),
        0 2px 0 var(--bevel-material-edge),
        0 3px 5px var(--bevel-shade);
      --bevel-depth-pressed:
        inset 0 1px 2px var(--bevel-shade),
        0 1px 0 var(--bevel-highlight);
      /* A whole object resting on the page. */
      --bevel-depth-object:
        inset 0 1px 0 var(--bevel-highlight),
        inset 0 -1px 0 var(--bevel-shade),
        0 0 0 1px var(--bevel-material-edge),
        0 1px 1px var(--bevel-cast),
        0 6px 12px -2px var(--bevel-cast),
        0 18px 32px -8px var(--bevel-cast);
      /* The shadow under a loose object, for filter: drop-shadow(). */
      --bevel-depth-cast-sm: 0 4px 6px var(--bevel-cast);
      /* A thin object (a card) lying on another surface. */
      --bevel-depth-sheet:
        inset 0 1px 0 var(--bevel-highlight-strong),
        inset 0 0 0 1px oklch(0 0 0 / 0.12),
        0 -1px 2px oklch(0 0 0 / 0.18);
    
      /* 4. Motion */
      --bevel-ease-out: cubic-bezier(0.23, 1, 0.32, 1);
      --bevel-ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
      /* Underdamped spring, ~8% overshoot. Pure CSS; pair with --bevel-duration-move or longer. */
      --bevel-ease-spring: linear(0, 0.06, 0.202, 0.38, 0.561, 0.723, 0.856, 0.955, 1.023, 1.062, 1.081, 1.083, 1.075, 1.062, 1.047, 1.032, 1.019, 1.009, 1.002, 0.997, 0.994, 0.993, 0.993, 0.994, 1);
      --bevel-duration-press: 100ms; /* a control giving under a finger */
      --bevel-duration-small: 200ms; /* hover lifts, tabs, small state changes */
      --bevel-duration-move: 300ms; /* objects moving: cards, drawers */
      --bevel-duration-ambient: 520ms; /* whole-scene changes: lighting */
      --bevel-stagger: 30ms;
      --bevel-lift: 3px; /* how far an object rises on hover */
      --bevel-perspective: 30rem; /* viewing distance for parts that turn in 3D (covers, flaps) */
      /* Unchosen items in a group sit back in shadow. */
      --bevel-dim: 0.82;
    
      /* 5. Shape */
      --bevel-radius-xs: 0.25rem;
      --bevel-radius-sm: 0.5rem;
      --bevel-radius-md: 0.625rem;
      --bevel-radius-lg: 1rem;
      --bevel-radius-xl: 1.5rem;
      --bevel-stitch-width: 1.5px;
      --bevel-stitch-inset: 0.375rem;
      /* Amounts: the figure, its secondary parts (currency, cents), labels, fine print. */
      --bevel-amount: 2.25rem;
      --bevel-amount-minor: 1.25rem;
      --bevel-label: 0.75rem;
      --bevel-fine: 0.6875rem;
      --bevel-tracking-label: 0.08em; /* stamped labels */
      --bevel-tracking-print: 0.06em; /* printed marks on cards */
    
      /* Paper and ink */
      --bevel-paper: oklch(0.975 0.008 85);
      --bevel-paper-foreground: oklch(0.28 0.02 260);
      --bevel-paper-muted: oklch(0.48 0.015 260);
      --bevel-paper-rule: oklch(0.78 0.04 240 / 0.45);
      --bevel-paper-margin: oklch(0.68 0.14 25 / 0.45);
      --bevel-fibre: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='f'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9 .35' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .18 0 0 0 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23f)'/%3E%3C/svg%3E");
      /* Ink soaks unevenly: mostly solid with pale specks. Used as a mask. */
      --bevel-ink-texture: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='i'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -2.4 0 0 0 2.1'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23i)'/%3E%3C/svg%3E");
      --bevel-ink-red: oklch(0.52 0.19 27);
      --bevel-ink-blue: oklch(0.44 0.14 262);
      --bevel-ink-green: oklch(0.5 0.12 155);
      --bevel-ink-black: oklch(0.3 0.01 260);
      --bevel-ink-amber: oklch(0.55 0.13 65);
    
      /* Brass and ivory: instrument hardware and dial faces */
      --bevel-brass: oklch(0.76 0.11 82);
      --bevel-brass-edge: oklch(0.52 0.08 70);
      --bevel-ivory: oklch(0.95 0.025 90);
      --bevel-ivory-foreground: oklch(0.25 0.015 60);
      --bevel-needle: oklch(0.55 0.2 27);
    
      /* Desk stationery: legal pads, pens, pencils, exhibit stickers */
      --bevel-pad: oklch(0.95 0.075 98); /* yellow legal pad */
      --bevel-pad-foreground: oklch(0.27 0.03 255);
      --bevel-pad-rule: oklch(0.72 0.06 235 / 0.45);
      --bevel-pad-margin: oklch(0.62 0.16 25 / 0.55);
      --bevel-pen-red: oklch(0.52 0.2 27); /* correcting pen */
      --bevel-pen-blue: oklch(0.42 0.15 262); /* writing pen */
      --bevel-pencil: oklch(0.55 0.01 260); /* not written in ink yet */
      --bevel-sticker: oklch(0.9 0.12 95); /* exhibit sticker */
      --bevel-sticker-foreground: oklch(0.26 0.03 70);
      /* Handwriting. Bring a marker font (e.g. Permanent Marker) for the full effect; these are fallbacks. */
      --bevel-font-marker: "Permanent Marker", "Marker Felt", "Chalkboard SE", ui-rounded, "Arial Rounded MT Bold", system-ui, sans-serif;
    }
    
    .dark {
      --bevel-highlight: oklch(1 0 0 / 0.14);
      /* Paper under dim light, not inverted: it's still paper. */
      --bevel-paper: oklch(0.88 0.008 85);
      --bevel-ivory: oklch(0.86 0.022 90);
      --bevel-pad: oklch(0.86 0.07 98);
      --bevel-cast: oklch(0 0 0 / 0.55);
    }
    
    /* Depth */
    @utility bevel-recessed {
      background-color: var(--bevel-material-recess);
      box-shadow: var(--bevel-depth-recessed);
    }
    @utility bevel-flush {
      box-shadow: var(--bevel-depth-flush);
    }
    @utility bevel-raised {
      background-color: color-mix(in oklch, var(--bevel-material), white 7%);
      background-image: linear-gradient(to bottom, var(--bevel-highlight), transparent 60%);
      box-shadow: var(--bevel-depth-raised);
    }
    @utility bevel-pressed {
      background-color: color-mix(in oklch, var(--bevel-material), black 4%);
      background-image: none;
      box-shadow: var(--bevel-depth-pressed);
    }
    @utility bevel-object {
      box-shadow: var(--bevel-depth-object);
    }
    
    /* Materials */
    @utility bevel-leather {
      background-color: var(--bevel-material);
      background-image:
        var(--bevel-grain),
        linear-gradient(to bottom, var(--bevel-highlight), transparent 28%, transparent 72%, var(--bevel-shade));
      background-size: var(--bevel-grain-size) var(--bevel-grain-size), 100% 100%;
      background-blend-mode: soft-light, normal;
    }
    @utility bevel-plastic {
      background-color: var(--bevel-material);
      background-image:
        linear-gradient(115deg, var(--bevel-highlight-strong), transparent 40%),
        linear-gradient(to bottom, transparent 25%, oklch(0 0 0 / 0.45));
      box-shadow: var(--bevel-depth-sheet);
    }
    @utility bevel-metal {
      background-color: var(--bevel-material);
      background-image:
        linear-gradient(to bottom, var(--bevel-highlight-strong), transparent 45%, var(--bevel-shade) 100%),
        repeating-linear-gradient(90deg, oklch(1 0 0 / 0.06) 0 1px, transparent 1px 3px);
      box-shadow:
        inset 0 0 0 1px oklch(0 0 0 / 0.25),
        inset 0 1px 0 var(--bevel-highlight-strong);
    }
    
    /* Details */
    @utility bevel-deboss {
      text-shadow: var(--bevel-deboss);
    }
    @utility bevel-stitch {
      border-color: var(--bevel-stitch, oklch(0.88 0.05 82 / 0.8));
      border-style: dashed;
      border-width: var(--bevel-stitch-width);
      filter: drop-shadow(0 1px 0 var(--bevel-shade));
    }
    /* A lit fill sitting in a recess (meters, progress). Colour from --bevel-fill. */
    @utility bevel-fill {
      background-color: var(--bevel-fill);
      background-image: linear-gradient(to bottom, oklch(1 0 0 / 0.55), transparent 55%, oklch(0 0 0 / 0.18));
      box-shadow: 0 0 6px -1px var(--bevel-fill);
    }
    /* A small indicator light. Colour from --bevel-led. */
    @utility bevel-led {
      background-color: var(--bevel-led);
      background-image: radial-gradient(circle at 35% 30%, oklch(1 0 0 / 0.85), transparent 45%);
      box-shadow:
        0 0 6px var(--bevel-led),
        inset 0 -1px 1px oklch(0 0 0 / 0.35),
        0 1px 0 var(--bevel-highlight);
    }
    
    /* Paper: a sheet resting on the surface, with fibre in it. */
    @utility bevel-paper {
      background-color: var(--bevel-paper);
      background-image: var(--bevel-fibre);
      background-size: 240px 240px;
      color: var(--bevel-paper-foreground);
      box-shadow:
        0 1px 1px var(--bevel-cast),
        0 6px 14px -4px var(--bevel-cast);
    }
    /* Ink on paper: multiplies into the surface and soaks unevenly. Colour from --bevel-ink. */
    @utility bevel-ink {
      color: var(--bevel-ink, var(--bevel-ink-red));
      border-color: currentColor;
      mix-blend-mode: multiply;
      mask-image: var(--bevel-ink-texture);
      mask-size: 160px 160px;
    }
    /* Glass over an instrument window: one reflection from the light above. Put on an overlay element. */
    @utility bevel-glass {
      pointer-events: none;
      background-image: linear-gradient(165deg, var(--bevel-highlight-strong) 0%, transparent 38%, transparent 70%, var(--bevel-shade) 100%);
    }
    
    /* Material families: one per screen. Set on the screen's container. */
    [data-bevel-family="leather"] {
      --bevel-cast: oklch(0.25 0.03 50 / 0.22);
    }
    [data-bevel-family="paper"] {
      --bevel-cast: oklch(0.3 0.01 250 / 0.16);
    }
    .dark :is([data-bevel-family="leather"], [data-bevel-family="paper"]) {
      --bevel-cast: oklch(0 0 0 / 0.55);
    }
    
    /*
     * Ruled paper: rules every --bevel-rule-height and a margin line at
     * --bevel-rule-margin. Set --bevel-sheet for the paper colour. The ruling
     * scrolls with the content.
     */
    @utility bevel-ruled {
      background-color: var(--bevel-sheet, var(--bevel-paper));
      background-image:
        linear-gradient(
          to right,
          transparent calc(var(--bevel-rule-margin) - 1px),
          var(--bevel-sheet-margin, var(--bevel-paper-margin)) calc(var(--bevel-rule-margin) - 1px) calc(var(--bevel-rule-margin) + 1px),
          transparent calc(var(--bevel-rule-margin) + 1px)
        ),
        repeating-linear-gradient(
          to bottom,
          transparent 0 calc(var(--bevel-rule-height) - 1px),
          var(--bevel-sheet-rule, var(--bevel-paper-rule)) calc(var(--bevel-rule-height) - 1px) var(--bevel-rule-height)
        ),
        var(--bevel-fibre);
      background-size: 100% 100%, 100% 100%, 240px 240px;
      background-attachment: local;
      color: var(--bevel-paper-foreground);
      box-shadow:
        0 1px 1px var(--bevel-cast),
        0 6px 14px -4px var(--bevel-cast);
    }
  3. Install the components Gauge and dial is built from: Amount.

  4. Copy dial.tsx into components/ui.

    components/dial.tsx
    "use client"
    
    import * as React from "react"
    import { Meter as MeterPrimitive } from "@base-ui/react/meter"
    import { cn } from "cn"
    import { formatAmount } from "@/lib/format-amount"
    
    /* The needle sweeps 270°, from bottom-left (-135°) to bottom-right (135°). */
    const SWEEP = 270
    const START = -SWEEP / 2
    
    type Scale = { min: number; max: number }
    const ScaleContext = React.createContext<Scale & { value: number }>({ min: 0, max: 100, value: 0 })
    
    function angleFor(value: number, { min, max }: Scale) {
      const t = max === min ? 0 : (Math.min(Math.max(value, min), max) - min) / (max - min)
      return START + t * SWEEP
    }
    
    /** Ticks, marks, needle and glass. Shared by Gauge and Dial. */
    function Face({
      ticks,
      dragging,
      children,
    }: {
      ticks: number
      dragging?: boolean
      children?: React.ReactNode
    }) {
      const { value, ...scale } = React.useContext(ScaleContext)
      return (
        <span
          data-slot="dial-face"
          className="bevel-metal relative block aspect-square w-full rounded-full p-(--bevel-stitch-inset) shadow-(--bevel-depth-object)"
        >
          <span className="relative block size-full overflow-hidden rounded-full bg-(--bevel-ivory) text-(--bevel-ivory-foreground) shadow-(--bevel-depth-recessed)">
            <svg viewBox="0 0 100 100" aria-hidden className="absolute inset-0 size-full">
              {Array.from({ length: ticks + 1 }, (_, i) => {
                const major = i % 5 === 0 || i === ticks
                return (
                  <line
                    key={i}
                    x1="50"
                    y1={major ? 9 : 11}
                    x2="50"
                    y2="16"
                    stroke="currentColor"
                    strokeWidth={major ? 1.6 : 0.8}
                    transform={`rotate(${START + (i / ticks) * SWEEP} 50 50)`}
                  />
                )
              })}
            </svg>
            {children}
            <span
              aria-hidden
              data-slot="dial-needle"
              data-dragging={dragging ? "" : undefined}
              className="absolute inset-0 transition-[rotate] duration-(--bevel-duration-ambient) ease-(--bevel-ease-spring) data-dragging:duration-0 motion-reduce:transition-none"
              style={{ rotate: `${angleFor(value, scale)}deg` }}
            >
              <svg viewBox="0 0 100 100" className="size-full drop-shadow-(--bevel-depth-cast-sm)">
                <path d="M50 12 L52 50 L48 50 Z" fill="var(--bevel-needle)" />
                <path d="M48 50 L52 50 L51 60 L49 60 Z" fill="currentColor" />
              </svg>
            </span>
            <span
              aria-hidden
              className="bevel-metal absolute top-1/2 left-1/2 size-[12%] -translate-1/2 rounded-full"
            />
            <span aria-hidden className="bevel-glass absolute inset-0 rounded-full" />
          </span>
        </span>
      )
    }
    
    type DialMarkProps = Omit<React.ComponentProps<"span">, "children"> & {
      value: number
      tone?: "red" | "amber" | "green"
    }
    
    /** A painted mark on the scale: a threshold, a limit, a target. Decorative; describe it in text too. */
    function DialMark({ value, tone = "red", className, style, ...props }: DialMarkProps) {
      const scale = React.useContext(ScaleContext)
      return (
        <span
          aria-hidden
          data-slot="dial-mark"
          data-tone={tone}
          className={cn("absolute inset-0", className)}
          style={{ rotate: `${angleFor(value, scale)}deg`, ...style }}
          {...props}
        >
          <svg viewBox="0 0 100 100" className="size-full">
            <path d="M48.2 6 L51.8 6 L51 18 L49 18 Z" fill="var(--dial-mark)" />
          </svg>
        </span>
      )
    }
    
    type GaugeProps = MeterPrimitive.Root.Props & {
      /** Number of tick intervals on the scale. */
      ticks?: number
      /** Marks (DialMark) painted on the face. */
      marks?: React.ReactNode
    }
    
    /** A read-only instrument: the needle shows a value on a scale. Announced as a meter. */
    function Gauge({
      className,
      value,
      min = 0,
      max = 100,
      ticks = 20,
      marks,
      children,
      ...props
    }: GaugeProps) {
      return (
        <ScaleContext value={{ min, max, value }}>
          <MeterPrimitive.Root
            data-slot="gauge"
            value={value}
            min={min}
            max={max}
            className={cn("flex w-48 flex-col items-center gap-3 text-center", className)}
            {...props}
          >
            <Face ticks={ticks}>{marks}</Face>
            {children}
          </MeterPrimitive.Root>
        </ScaleContext>
      )
    }
    
    function GaugeLabel({ className, ...props }: MeterPrimitive.Label.Props) {
      return (
        <MeterPrimitive.Label
          data-slot="gauge-label"
          className={cn("text-sm text-muted-foreground", className)}
          {...props}
        />
      )
    }
    
    function GaugeValue({ className, ...props }: MeterPrimitive.Value.Props) {
      return (
        <MeterPrimitive.Value
          data-slot="gauge-value"
          className={cn("text-lg font-semibold tabular-nums", className)}
          {...props}
        />
      )
    }
    
    type DialProps = Omit<React.ComponentProps<"div">, "defaultValue" | "onChange" | "children"> & {
      value?: number
      defaultValue?: number
      onValueChange?: (value: number) => void
      min?: number
      max?: number
      step?: number
      /** Number of tick intervals on the scale. */
      ticks?: number
      /** Formats the value for the readout and for screen readers. */
      currency?: string
      format?: Intl.NumberFormatOptions
      locale?: Intl.LocalesArgument
      /** Marks (DialMark) painted on the face. */
      marks?: React.ReactNode
      /** A name for the control, shown below the dial. */
      label: React.ReactNode
      disabled?: boolean
    }
    
    /**
     * An adjustable dial: turn it with a drag, or arrow keys, Page Up/Down, Home and End.
     * Announced as a slider.
     */
    function Dial({
      className,
      value: valueProp,
      defaultValue,
      onValueChange,
      min = 0,
      max = 100,
      step = 1,
      ticks = 20,
      currency,
      format,
      locale,
      marks,
      label,
      disabled,
      ...props
    }: DialProps) {
      const [uncontrolled, setUncontrolled] = React.useState(defaultValue ?? min)
      const value = valueProp ?? uncontrolled
      const [dragging, setDragging] = React.useState(false)
      const labelId = React.useId()
      const text = formatAmount({ value, currency, format, locale })
    
      const commit = (next: number) => {
        const snapped = Math.min(max, Math.max(min, Math.round((next - min) / step) * step + min))
        if (snapped === value) return
        if (valueProp === undefined) setUncontrolled(snapped)
        onValueChange?.(snapped)
      }
    
      const fromPointer = (event: React.PointerEvent<HTMLDivElement>) => {
        const rect = event.currentTarget.getBoundingClientRect()
        const dx = event.clientX - (rect.left + rect.width / 2)
        const dy = event.clientY - (rect.top + rect.height / 2)
        // 0° points up; clockwise is positive. Below the scale, snap to the nearer end.
        let angle = (Math.atan2(dx, -dy) * 180) / Math.PI
        if (angle > SWEEP / 2) angle = SWEEP / 2
        if (angle < -SWEEP / 2) angle = -SWEEP / 2
        commit(min + ((angle - START) / SWEEP) * (max - min))
      }
    
      const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
        const big = Math.max(step, (max - min) / 10)
        const next = {
          ArrowUp: value + step,
          ArrowRight: value + step,
          ArrowDown: value - step,
          ArrowLeft: value - step,
          PageUp: value + big,
          PageDown: value - big,
          Home: min,
          End: max,
        }[event.key]
        if (next === undefined) return
        event.preventDefault()
        commit(next)
      }
    
      return (
        <ScaleContext value={{ min, max, value }}>
          <div
            data-slot="dial"
            data-disabled={disabled ? "" : undefined}
            className={cn(
              "flex w-48 flex-col items-center gap-3 text-center data-disabled:opacity-60",
              className
            )}
            {...props}
          >
            <div
              role="slider"
              tabIndex={disabled ? -1 : 0}
              aria-labelledby={labelId}
              aria-valuemin={min}
              aria-valuemax={max}
              aria-valuenow={value}
              aria-valuetext={text}
              aria-disabled={disabled || undefined}
              data-slot="dial-control"
              className="w-full cursor-grab touch-none rounded-full outline-none select-none focus-visible:ring-3 focus-visible:ring-ring/60 focus-visible:ring-offset-2 focus-visible:ring-offset-background active:cursor-grabbing aria-disabled:cursor-not-allowed"
              onKeyDown={disabled ? undefined : onKeyDown}
              onPointerDown={(event) => {
                if (disabled || event.button !== 0) return
                event.currentTarget.setPointerCapture(event.pointerId)
                setDragging(true)
                fromPointer(event)
              }}
              onPointerMove={(event) => {
                if (dragging) fromPointer(event)
              }}
              onPointerUp={() => setDragging(false)}
              onPointerCancel={() => setDragging(false)}
            >
              <Face ticks={ticks} dragging={dragging}>
                {marks}
              </Face>
            </div>
            <span id={labelId} data-slot="dial-label" className="text-sm text-muted-foreground">
              {label}
            </span>
            <span aria-hidden data-slot="dial-value" className="text-lg font-semibold tabular-nums">
              {text}
            </span>
          </div>
        </ScaleContext>
      )
    }
    
    export { Gauge, GaugeLabel, GaugeValue, Dial, DialMark }
    export type { GaugeProps, DialProps, DialMarkProps }
  5. Copy dial.css into your styles.

    styles/dial.css
    /*
     * Gauge and Dial: a needle over an ivory face in a brass bezel. Builds on
     * bevel.css (brass, ivory, needle, glass, motion).
     */
    
    :root {
      --dial-mark-red: oklch(0.58 0.2 27);
      --dial-mark-amber: oklch(0.75 0.16 70);
      --dial-mark-green: oklch(0.6 0.15 150);
    }
    
    [data-slot="gauge"],
    [data-slot="dial"] {
      --bevel-material: var(--bevel-brass);
      --bevel-material-edge: var(--bevel-brass-edge);
    }
    
    [data-slot="dial-mark"] {
      --dial-mark: var(--dial-mark-red);
    }
    [data-slot="dial-mark"][data-tone="amber"] {
      --dial-mark: var(--dial-mark-amber);
    }
    [data-slot="dial-mark"][data-tone="green"] {
      --dial-mark: var(--dial-mark-green);
    }
  6. Import the stylesheets after Tailwind in your global CSS, foundation first.

    globals.css
    @import "./bevel.css";
    @import "./dial.css";

Anatomy

Each part is its own component. Leave out the ones you don't need.

Anatomy
<Gauge value={12} max={30} marks={<DialMark value={5} />}>
  <GaugeLabel />
  <GaugeValue />
</Gauge>

<Dial label="Keep above" value={30} onValueChange={setValue} />

Parts

PartRendersWhat it does
GaugeMeterRead-only. value, min, max, ticks, marks. Announced as a meter; pass getAriaValueText to describe marks.
GaugeLabel / GaugeValueMeter partsThe name and the formatted value.
Dialdiv (slider)Adjustable. value, defaultValue, onValueChange, min, max, step, label, and Amount formatting for the readout. Arrows, Page Up/Down, Home, End.
DialMarkspanA painted mark on the scale: red, amber or green. Decorative.