AI

Sampling Controls

A controlled panel for temperature, top-p, max tokens and the penalties — editable value badges, a worded creative-to-precise scale, per-parameter revert, and a nudge when temperature and top-p are tuned together.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ChevronDown, RotateCcw, SlidersHorizontal, TriangleAlert, Undo2 } from "lucide-react"
import { cn } from "@/lib/utils"

/* -------------------------------------------------------------------------- *
 * Sampling Controls
 *
 * A controlled panel for the numbers you hand a completion endpoint. Two ideas
 * carry the whole design:
 *
 * 1. The value object is SPARSE. A key is present only while it differs from
 *    the field's default, so the object can be spread straight into a request

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/sampling-controls.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "SamplingControls" component: the panel
where a user tunes the numbers that go into a completion request. Sliders are
hand-rolled (pointer capture + role="slider"), icons from lucide-react, no
other dependency.

Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>
  minus "onChange" and "defaultValue"; the rest spreads onto the root.
- value: SamplingValue — Record<string, number | undefined>. SPARSE: a key is
  present only while it differs from that field's default. This is the whole
  design; the object is meant to be spread straight into a request body.
- onChange(next: SamplingValue) — every drag frame, key press, committed badge
  edit and reset.
- onCommit?(next: SamplingValue) — once per settled interaction: pointer
  release, key press, Enter/blur on a badge, a reset. Wire refetches here.
- fields?: SamplingField[] (default SAMPLING_FIELDS, exported)
    { key, label, min, max, step, defaultValue, largeStep?, unit?, hint?,
      advanced?, scale? }
  Default set: temperature (0..2, step .01, default .7, has a scale), top-p
  (0..1, step .01, default 1), max tokens (64..8192, step 32, default 1024,
  unit "tok"), then advanced: frequency penalty and presence penalty
  (-2..2, step .1, default 0) and top-k (0..100, step 1, default 0).
- scale?: SamplingBand[] — { from, label, hint? }, ascending; `from` is
  inclusive and the band runs to the next band's `from` or to the field max.
  Temperature ships Precise / Balanced / Creative / Wild.
- conflicts?: SamplingConflict[] (default SAMPLING_CONFLICTS) —
  { keys: [string, string]; message }. Default pair: temperature vs top-p.
- title?: string (default "Sampling"), disabled?: boolean,
  defaultAdvancedOpen?: boolean (default false).

Behavior — the sparse value object
- Resolved value of a field = value[key] ?? field.defaultValue. A missing key
  means "leave it at the provider default", so an untouched panel adds nothing
  to the request body.
- Every write clamps to [min, max] and snaps to the step grid (snap first, then
  clamp, so the exact max is reachable even when it is off-grid). Round with
  the step's decimal count so 0.1 steps never leave floating-point tails.
- If the snapped result equals the default, DELETE the key instead of writing
  it. "Modified", "shown as changed" and "sent" then all mean the same thing,
  and dragging a slider home shrinks the payload again.
- Reverting (per-field button, conflict action, Reset all) deletes keys
  outright and never routes through the grid, so a consumer whose default does
  not sit on the step grid can still get back to clean.
- Guard a step of 0 (Math.abs(step) || 1) — one bad field spec must not turn
  every value into NaN.
- An incoming value outside [min, max] is clamped for GEOMETRY only; the badge
  and aria-valuenow keep showing what the caller passed. The panel never
  rewrites the caller's object on its own.

Behavior — the slider
- One row per field: label + editable value badge, optional hint, track,
  optional worded scale, active band line.
- The track fill runs from the DEFAULT tick to the current value, not from the
  minimum. A bipolar parameter (-2..2, default 0) then reads as "pushed up" or
  "pulled down"; top-p (default 1 = max) reads as "how far you pulled it down".
  Draw a small tick at the default position on every track.
- Pointer: pointerdown on the track jumps to that position, sets pointer
  capture (so the drag survives leaving the track — no window listeners to
  register or clean up) and focuses the thumb; pointermove writes; pointerup /
  pointercancel end the gesture and fire onCommit once. Re-check `disabled` on
  every move so a consumer that locks the panel from onChange stops receiving
  frames mid-gesture. touch-none on the track.
- Keyboard on the thumb: Arrow keys = ±step, PageUp/PageDown = ±largeStep
  (default step*10), Home/End = min/max. Each press is its own committed
  interaction. preventDefault so the page does not scroll.
- The value badge is a text input, not a static label: focus selects all,
  Enter commits and re-selects, Escape reverts to the current value, blur
  commits, unparseable or empty text silently reverts. Commit clamps and snaps
  like any other write. If a drag started while the badge still held focus,
  discard the pending text — the gesture is the newer intent.
- Transitions on left/width are disabled while dragging (the thumb must track
  the pointer exactly) and re-enabled for snap-back; motion-reduce turns them
  off entirely without changing any value.

Behavior — worded scale, dirt tracking, disclosure
- The active band is the last one whose `from` the value has reached. Render
  the bands as a proportional strip under the track (each band's width is its
  share of the range), highlight the active one, and print "<label> — <hint>"
  under it. The band label also goes into aria-valuetext ("0.92, Creative"),
  so a screen-reader user gets the same meaning as the strip.
- A field is dirty when its resolved value differs from its default. A dirty
  field shows a revert button next to its badge and tints the badge. Count
  dirty fields in the header ("3 changed"); Reset all only acts while there is
  something to reset. Mark it aria-disabled with the guard in the handler, never
  the native `disabled` attribute — the press itself is what makes the button
  unavailable, and a node that goes disabled under the caret is blurred to
  <body>.
- The revert button unmounts the instant the field goes clean, so move focus to
  that field's thumb BEFORE emitting — never drop a keyboard user on <body>.
- Advanced group: a disclosure button with aria-expanded + aria-controls; the
  region stays mounted and is hidden with a display utility (the `hidden`
  attribute loses to any Tailwind display class). The button carries its own
  "N changed" badge, so a collapsed group can never hide a modified value.

Behavior — the mutually exclusive pair
- A conflict fires when BOTH keys of a pair are dirty — derived from values,
  not from interaction, so a preset that arrives already conflicting is caught
  too.
- Render it as a gentle notice, not an error: neutral surface, an alert glyph,
  the message, a "Reset <second field>" action and "Dismiss". It never blocks
  and never rewrites anything by itself.
- Mount the live region (aria-live="polite") EMPTY and keep it mounted; a
  region that appears together with its text is often missed. Point the two
  thumbs at the notice with aria-describedby while it is up.
- Dismissal is one-shot per episode: remember the dismissed notice id, and drop
  that memory as soon as the pair goes clean again, so the nudge is re-armed
  for the next time it is earned. Both actions move focus to a thumb before the
  notice unmounts.

Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground for the shell, border,
  bg-muted for the track and inert badges, bg-primary for the fill,
  border-primary for the thumb, bg-primary/10 + border-primary/40 for a dirty
  badge, bg-foreground/30 for the default tick, bg-background for band
  separators cut into the track, text-muted-foreground for hints, ring/ring-50
  for focus. No hex, no rgb(), no oklch().
- Merge className through cn() on the root; keep every decorative node
  aria-hidden; the thumb is the only focusable element of a track.
- The shell is role="group" labelled by its own heading, so a screen reader can
  jump the whole panel; role="slider" with aria-labelledby (the row label),
  aria-valuemin/max/now,
  aria-valuetext, aria-orientation, aria-disabled and tabIndex -1 when
  disabled. The badge input gets its own aria-label ("Temperature value").
- disabled dims the shell, stops pointer and keyboard writes and takes every
  value control out of the tab order, but keeps the numbers readable and leaves
  the Advanced disclosure operable — reading a hidden value must not require
  unlocking the panel first.
- Ship the reveal keyframes in a React 19 hoisted <style href precedence> so
  duplicate instances dedupe, and gate the animation with
  motion-reduce:[animation:none].

Customization levers
- fields is the main lever: reorder, drop, retitle, re-range, move any field in
  or out of `advanced`, or replace the set wholesale (llama.cpp's min-p /
  repeat-penalty / mirostat, an image model's guidance scale and steps). Keys
  are request-body field names — rename them to match your API.
- step vs largeStep tunes keyboard feel: a coarse largeStep makes long ranges
  (max tokens) usable from the keyboard without touching drag precision.
- scale is optional per field and free-form: add bands to top-p, or drop
  temperature's to get a bare slider. Bands are rendered proportionally, so
  their widths follow whatever `from` values you pick.
- conflicts is data: add pairs (top-k vs min-p), reword the message, or pass []
  to remove the nudge entirely. The action always resets the SECOND key —
  order the pair so the one people should leave alone comes last.
- Density: the row gap (gap-5), the hint lines and the band strip are the three
  things to cut for a compact sidebar; keeping label + badge + track alone
  still leaves a complete control.
- Chrome: `title`, the header icon and the "N changed" pill are independent —
  drop them for an embedded panel that already sits under a heading.
- Wiring: onChange for live preview, onCommit for anything expensive (persist,
  refetch a cost estimate, restart a stream). Both hand you the same sparse
  object; spread it into your request body as-is.

Concepts

  • Sparse value object — a parameter lives in the object only while it differs from its default, so "untouched" and "explicitly sent the default" stay different things; dragging a slider back home deletes the key and shrinks the request body again.
  • Default-anchored fill — the coloured part of the track runs between the default tick and the current value instead of from the minimum, which is the only way a bipolar penalty (-2…2, default 0) and a ceiling-valued top-p (default 1) read correctly on the same widget.
  • Worded band scale — temperature's number is meaningless on its own, so the range is cut into named bands (Precise / Balanced / Creative / Wild) drawn proportionally under the track; the active band's label also rides in aria-valuetext, so the meaning is not sighted-only.
  • Pairing nudge with a one-shot dismissal — two parameters that fight each other raise a polite, non-blocking notice derived from the values (so a preset that arrives already conflicting is caught too); dismissing it hides it until the pair returns to clean, at which point the nudge is armed again.
  • Disclosure that cannot hide a change — the collapsed Advanced group carries its own "N changed" count, so progressive disclosure never turns into a value the user cannot see; the region stays mounted so aria-controls always points at something real.
  • Change versus commitonChange fires on every frame of a drag for live preview, onCommit fires once when the interaction settles, which is where a save, a refetch or a cost re-estimate belongs.

On This Page