Styled numeric range slider, built on the @foldkit/ui Slider submodel.

Preview
Style
Basic
Range
Vertical
Controlled
50%

Adjust the slider to change the value.

Disabled
src/demo/views/slider.ts384 lines
import { Subscription, Update } from 'foldkit'
import { Match as M, Option, Schema as S } from 'effect'
import { evo } from 'foldkit/struct'
import { defineMessageUnion } from 'foldkit/message'
import type { Html, HtmlBuilder } from 'foldkit/html'

import * as Slider from '../../generated/registry/ui/slider'
import { field, fieldDescription, fieldLabel } from '../../generated/registry/ui/fieldset'

import { defineSlice, type UpdateReturn } from '../slice'
import type { Model, Message as AppMessage } from '../assemble'

const sliderRange = { min: 0, max: 100, step: 1 } as const

const Message = defineMessageUnion({
  GotSliderBasicMessage: { message: Slider.Message },
  GotSliderRangeMessage: { message: Slider.Message },
  GotSliderVerticalAMessage: { message: Slider.Message },
  GotSliderVerticalBMessage: { message: Slider.Message },
  GotSliderControlledMessage: { message: Slider.Message },
})

type SliderStyledInputs = Parameters<typeof Slider.styledViewInputs>[0]

const sliderSubmodel = (
  model: Slider.Model,
  styledInputs: SliderStyledInputs,
  toParentMessage: (message: Slider.Message) => AppMessage,
  h: HtmlBuilder<AppMessage>,
): Html =>
  h.submodel({
    slotId: model.id,
    model,
    view: Slider.view,
    viewInputs: Slider.styledViewInputs({ ...sliderRange, ...styledInputs }, h),
    toParentMessage,
  })

export const sliderView = (model: Model, h: HtmlBuilder<AppMessage>): Html =>
  h.div(
    [h.Class('flex w-full flex-col gap-8')],
    [
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Basic']),
          h.div(
            [h.Class('flex w-full max-w-xs flex-col gap-2')],
            [
              sliderSubmodel(
                model.sliderBasic,
                { value: model.basicValue, ariaLabel: 'Value' },
                (message) => Message.GotSliderBasicMessage({ message }),
                h,
              ),
            ],
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Range']),
          h.div(
            [h.Class('flex w-full max-w-xs')],
            [
              sliderSubmodel(
                model.sliderRange,
                { value: model.rangeValue, ariaLabel: 'Range' },
                (message) => Message.GotSliderRangeMessage({ message }),
                h,
              ),
            ],
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Vertical']),
          h.div(
            [h.Class('flex items-center gap-6')],
            [
              sliderSubmodel(
                model.sliderVerticalA,
                {
                  value: model.verticalAValue,
                  orientation: 'vertical',
                  rootClass: 'h-40',
                  ariaLabel: 'Vertical slider A',
                },
                (message) => Message.GotSliderVerticalAMessage({ message }),
                h,
              ),
              sliderSubmodel(
                model.sliderVerticalB,
                {
                  value: model.verticalBValue,
                  orientation: 'vertical',
                  rootClass: 'h-40',
                  ariaLabel: 'Vertical slider B',
                },
                (message) => Message.GotSliderVerticalBMessage({ message }),
                h,
              ),
            ],
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Controlled']),
          h.div(
            [h.Class('grid w-full max-w-xs gap-3')],
            [
              field<AppMessage>(
                {},
                [
                  fieldLabel<AppMessage>({ for: 'slider-demo-temperature' }, ['Temperature'], h),
                  h.div(
                    [h.Class('flex items-center justify-between gap-2')],
                    [
                      h.span(
                        [h.Class('text-sm text-muted-foreground')],
                        [`${String(model.controlledValue)}%`],
                      ),
                    ],
                  ),
                  sliderSubmodel(
                    model.sliderControlled,
                    {
                      value: model.controlledValue,
                      ariaLabelledBy: 'slider-demo-temperature',
                    },
                    (message) => Message.GotSliderControlledMessage({ message }),
                    h,
                  ),
                  fieldDescription<AppMessage>({}, ['Adjust the slider to change the value.'], h),
                ],
                h,
              ),
            ],
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Disabled']),
          h.div(
            [h.Class('flex w-full max-w-xs')],
            [
              sliderSubmodel(
                model.sliderDisabled,
                {
                  value: model.disabledValue,
                  isDisabled: true,
                  ariaLabel: 'Disabled slider',
                },
                (message) => Message.GotSliderBasicMessage({ message }),
                h,
              ),
            ],
          ),
        ],
      ),
    ],
  )

const foldOutBasic = M.type<Slider.OutMessage>().pipe(
  M.withReturnType<Update.Step<State, unknown>>(),
  M.tagsExhaustive({
    ChangedValue:
      ({ value }) =>
      (model) => ({ model: evo(model, { basicValue: () => value }) }),
  }),
)

const foldOutRange = M.type<Slider.OutMessage>().pipe(
  M.withReturnType<Update.Step<State, unknown>>(),
  M.tagsExhaustive({
    ChangedValue:
      ({ value }) =>
      (model) => ({ model: evo(model, { rangeValue: () => value }) }),
  }),
)

const foldOutVerticalA = M.type<Slider.OutMessage>().pipe(
  M.withReturnType<Update.Step<State, unknown>>(),
  M.tagsExhaustive({
    ChangedValue:
      ({ value }) =>
      (model) => ({ model: evo(model, { verticalAValue: () => value }) }),
  }),
)

const foldOutVerticalB = M.type<Slider.OutMessage>().pipe(
  M.withReturnType<Update.Step<State, unknown>>(),
  M.tagsExhaustive({
    ChangedValue:
      ({ value }) =>
      (model) => ({ model: evo(model, { verticalBValue: () => value }) }),
  }),
)

const foldOutControlled = M.type<Slider.OutMessage>().pipe(
  M.withReturnType<Update.Step<State, unknown>>(),
  M.tagsExhaustive({
    ChangedValue:
      ({ value }) =>
      (model) => ({ model: evo(model, { controlledValue: () => value }) }),
  }),
)

const makeFold = (
  read: (model: State) => Option.Option<Slider.Model>,
  write: (model: State, next: Slider.Model) => State,
  toParentMessage: (message: Slider.Message) => AppMessage,
  foldOutMessage: (out: Slider.OutMessage) => Update.Step<State, unknown>,
) =>
  Update.foldChild({
    update: Slider.update,
    read,
    write,
    toParentMessage,
    foldOutMessage,
  })

const folds = {
  basic: makeFold(
    (model) => Option.some(model.sliderBasic),
    (model, next) => evo(model, { sliderBasic: () => next }),
    (message) => Message.GotSliderBasicMessage({ message }),
    foldOutBasic,
  ),
  range: makeFold(
    (model) => Option.some(model.sliderRange),
    (model, next) => evo(model, { sliderRange: () => next }),
    (message) => Message.GotSliderRangeMessage({ message }),
    foldOutRange,
  ),
  verticalA: makeFold(
    (model) => Option.some(model.sliderVerticalA),
    (model, next) => evo(model, { sliderVerticalA: () => next }),
    (message) => Message.GotSliderVerticalAMessage({ message }),
    foldOutVerticalA,
  ),
  verticalB: makeFold(
    (model) => Option.some(model.sliderVerticalB),
    (model, next) => evo(model, { sliderVerticalB: () => next }),
    (message) => Message.GotSliderVerticalBMessage({ message }),
    foldOutVerticalB,
  ),
  controlled: makeFold(
    (model) => Option.some(model.sliderControlled),
    (model, next) => evo(model, { sliderControlled: () => next }),
    (message) => Message.GotSliderControlledMessage({ message }),
    foldOutControlled,
  ),
}

const fields = {
  sliderBasic: Slider.Model,
  sliderRange: Slider.Model,
  sliderVerticalA: Slider.Model,
  sliderVerticalB: Slider.Model,
  sliderControlled: Slider.Model,
  sliderDisabled: Slider.Model,
  basicValue: S.Number,
  rangeValue: S.Number,
  verticalAValue: S.Number,
  verticalBValue: S.Number,
  controlledValue: S.Number,
  disabledValue: S.Number,
}

const stateSchema = S.Struct(fields)
type State = typeof stateSchema.Type

type SliderMessage =
  | typeof Message.GotSliderBasicMessage.Type
  | typeof Message.GotSliderRangeMessage.Type
  | typeof Message.GotSliderVerticalAMessage.Type
  | typeof Message.GotSliderVerticalBMessage.Type
  | typeof Message.GotSliderControlledMessage.Type

const liftSliderSubscriptions = (
  name: string,
  read: (model: State) => Slider.Model,
  toParentMessage: (message: Slider.Message) => SliderMessage,
) => {
  const lifted = Subscription.lift({
    dragPointer: Slider.subscriptions.dragPointer,
    dragEscape: Slider.subscriptions.dragEscape,
  })<State, SliderMessage>({
    toChildModel: read,
    toParentMessage,
  })
  return {
    [`${name}DragPointer`]: lifted.dragPointer,
    [`${name}DragEscape`]: lifted.dragEscape,
  }
}

const sliderSubscriptions = Subscription.aggregate<State, SliderMessage>()(
  liftSliderSubscriptions(
    'basic',
    (model) => model.sliderBasic,
    (message) => Message.GotSliderBasicMessage({ message }),
  ),
  liftSliderSubscriptions(
    'range',
    (model) => model.sliderRange,
    (message) => Message.GotSliderRangeMessage({ message }),
  ),
  liftSliderSubscriptions(
    'verticalA',
    (model) => model.sliderVerticalA,
    (message) => Message.GotSliderVerticalAMessage({ message }),
  ),
  liftSliderSubscriptions(
    'verticalB',
    (model) => model.sliderVerticalB,
    (message) => Message.GotSliderVerticalBMessage({ message }),
  ),
  liftSliderSubscriptions(
    'controlled',
    (model) => model.sliderControlled,
    (message) => Message.GotSliderControlledMessage({ message }),
  ),
)

export { sliderSubscriptions as subscriptions }

export const slice = defineSlice({
  fields,
  init: {
    sliderBasic: Slider.init({ id: 'slider-basic', ...sliderRange }),
    sliderRange: Slider.init({ id: 'slider-range', ...sliderRange }),
    sliderVerticalA: Slider.init({ id: 'slider-vertical-a', ...sliderRange }),
    sliderVerticalB: Slider.init({ id: 'slider-vertical-b', ...sliderRange }),
    sliderControlled: Slider.init({ id: 'slider-controlled', ...sliderRange }),
    sliderDisabled: Slider.init({ id: 'slider-disabled', ...sliderRange }),
    basicValue: 50,
    rangeValue: 50,
    verticalAValue: 50,
    verticalBValue: 25,
    controlledValue: 50,
    disabledValue: 50,
  },
  messages: [
    Message.GotSliderBasicMessage,
    Message.GotSliderRangeMessage,
    Message.GotSliderVerticalAMessage,
    Message.GotSliderVerticalBMessage,
    Message.GotSliderControlledMessage,
  ],
  handlers: (model: State) => ({
    GotSliderBasicMessage: (payload: typeof Message.GotSliderBasicMessage.Type): UpdateReturn =>
      folds.basic(model, payload.message),
    GotSliderRangeMessage: (payload: typeof Message.GotSliderRangeMessage.Type): UpdateReturn =>
      folds.range(model, payload.message),
    GotSliderVerticalAMessage: (
      payload: typeof Message.GotSliderVerticalAMessage.Type,
    ): UpdateReturn => folds.verticalA(model, payload.message),
    GotSliderVerticalBMessage: (
      payload: typeof Message.GotSliderVerticalBMessage.Type,
    ): UpdateReturn => folds.verticalB(model, payload.message),
    GotSliderControlledMessage: (
      payload: typeof Message.GotSliderControlledMessage.Type,
    ): UpdateReturn => folds.controlled(model, payload.message),
  }),
  samples: [
    Message.GotSliderBasicMessage({
      message: Slider.Message.PressedKeyboardNavigation({
        direction: 'StepIncrement',
        value: 50,
      }),
    }),
  ],
  subscriptions: sliderSubscriptions,
})

This code is shown verbatim — it’s the exact file used for this preview. Much of the surrounding wiring (slice, fields, messages, handlers) belongs to this site’s demo harness; you only need the view and the state that matters for your own app. Adapt what you need.

Installation

Add this component to your project:

pnpm dlx shadcn@latest add @foldcn/slider

Source

The component ships as plain source — no build step, no wrapper. Copy it and make it yours.

registry/default/ui/slider.ts513 lines
/** Stateful submodel — import the whole module as a namespace and wire its
 *  Model/Message/init/update into your app:
 *  `import * as Slider from '@/components/ui/slider'`
 */
import { Effect, Equal, Match as M, Option, Schema as S, Stream, pipe } from 'effect'
import { Slider as FoldkitSlider } from '@foldkit/ui'
import { childAttributes, type Html, type HtmlBuilder } from 'foldkit/html'
import { defineView } from 'foldkit/submodel'
import * as Subscription from 'foldkit/subscription'

import { cn } from '@/lib/utils'

/**
 * foldcn gap vs upstream: single value / single thumb only (upstream is
 * multi-thumb; foldcn renders one thumb). Vertical orientation and
 * edge-aligned thumb/range geometry are owned by this module's view until
 * foldkit/ui gains parity (see foldcn issue tracker).
 */

export const init = FoldkitSlider.init
export const update = FoldkitSlider.update
export const Model = FoldkitSlider.Model
export type Model = typeof Model.Type
export const Message = FoldkitSlider.Message
export type Message = typeof Message.Type
export const OutMessage = FoldkitSlider.OutMessage
export type OutMessage = typeof OutMessage.Type

export const snapAndClamp = FoldkitSlider.snapAndClamp
export const fractionOfValue = FoldkitSlider.fractionOfValue
export const reflectRange = FoldkitSlider.reflectRange

export type InitConfig = FoldkitSlider.InitConfig
export type SliderAttributes = FoldkitSlider.SliderAttributes

/** Upstream SliderPrimitive.Control string. */
export const sliderRootClass =
  'data-vertical:min-h-40 relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:w-auto data-vertical:flex-col'

/** Upstream SliderPrimitive.Track string. */
export const sliderTrackClass = 'bg-muted rounded-full data-horizontal:h-1 data-horizontal:w-full data-vertical:h-full data-vertical:w-1 relative grow overflow-hidden select-none'

/** Upstream SliderPrimitive.Indicator string. */
export const sliderFilledTrackClass =
  'bg-primary select-none data-horizontal:h-full data-vertical:w-full'

/** Upstream SliderPrimitive.Thumb string. The disabled: variants are inert
 *  under foldkit (aria-/data- twins are inlined at style resolution). */
export const sliderThumbClass =
  'border-ring ring-ring/50 relative size-3 rounded-full border bg-white transition-[color,box-shadow] after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 block shrink-0 select-none disabled:pointer-events-none disabled:opacity-50'

export const sliderLabelClass = 'text-sm font-medium'

export const sliderValueClass = 'text-sm tabular-nums text-muted-foreground'

export const sliderRowClass = 'flex flex-col gap-2 w-full'

export const sliderHeaderClass = 'flex items-center justify-between'

const LEFT_MOUSE_BUTTON = 0
const THUMB_SIZE = '0.75rem'
const THUMB_HALF = '0.375rem'

const clamp = (value: number, min: number, max: number): number =>
  Math.min(Math.max(value, min), max)

const percentString = (fraction: number): string => `${String(Math.round(fraction * 10000) / 100)}%`

const labelId = (id: string): string => `${id}-label`

type KeyboardDirection =
  | 'StepDecrement'
  | 'StepIncrement'
  | 'PageDecrement'
  | 'PageIncrement'
  | 'Min'
  | 'Max'

const keyToDirection = (key: string): Option.Option<KeyboardDirection> =>
  M.value(key).pipe(
    M.withReturnType<KeyboardDirection>(),
    M.whenOr('ArrowRight', 'ArrowUp', () => 'StepIncrement' as const),
    M.whenOr('ArrowLeft', 'ArrowDown', () => 'StepDecrement' as const),
    M.when('PageUp', () => 'PageIncrement' as const),
    M.when('PageDown', () => 'PageDecrement' as const),
    M.when('Home', () => 'Min' as const),
    M.when('End', () => 'Max' as const),
    M.option,
  )

const isVerticalTrack = (element: Element): boolean =>
  element.hasAttribute('data-vertical') || element.closest('[data-vertical]') !== null

const trackElement = (id: string, root: Document | ShadowRoot): Option.Option<Element> =>
  Option.fromNullishOr(root.querySelector(`[data-slider-track-id="${CSS.escape(id)}"]`))

export const valueFromPointer = (
  clientX: number,
  clientY: number,
  track: Element,
  min: number,
  max: number,
): number => {
  const rect = track.getBoundingClientRect()
  if (isVerticalTrack(track)) {
    if (rect.height === 0) return min
    const fraction = clamp(1 - (clientY - rect.top) / rect.height, 0, 1)
    return min + fraction * (max - min)
  }
  if (rect.width === 0) return min
  const fraction = clamp((clientX - rect.left) / rect.width, 0, 1)
  return min + fraction * (max - min)
}

const DragActivity = S.Literals(['Idle', 'Active'])

const dragActivityFromModel = (model: Model): 'Idle' | 'Active' =>
  M.value(model.dragState).pipe(
    M.withReturnType<'Idle' | 'Active'>(),
    M.tag('Dragging', (): 'Idle' | 'Active' => 'Active'),
    M.orElse((): 'Idle' | 'Active' => 'Idle'),
  )

/** Drag subscriptions that map pointer position along the track axis, including
 *  vertical sliders marked with `data-vertical`. Pending foldkit upstream (#27).
 */
export const subscriptionsForRoot = (getTrackRoot: () => Document | ShadowRoot) =>
  Subscription.make<Model, Message>()((entry) => ({
    dragPointer: entry(
      {
        dragActivity: DragActivity,
        id: S.String,
        min: S.Number,
        max: S.Number,
      },
      {
        modelToDependencies: (model) => ({
          dragActivity: dragActivityFromModel(model),
          id: model.id,
          min: model.min,
          max: model.max,
        }),
        dependenciesToStream: ({ dragActivity, id, min, max }): Stream.Stream<Message> => {
          const pointerEvents = Stream.merge(
            Stream.fromEventListener(document, 'pointermove').pipe(
              Stream.mapEffect((event) =>
                Effect.sync(() => {
                  if (!(event instanceof PointerEvent)) return Option.none()
                  return Option.flatMap(trackElement(id, getTrackRoot()), (element) =>
                    Option.some(
                      Message.MovedDragPointer({
                        value: valueFromPointer(event.clientX, event.clientY, element, min, max),
                      }),
                    ),
                  )
                }),
              ),
              Stream.filter(Option.isSome),
              Stream.map((option) => option.value),
            ),
            Stream.fromEventListener(document, 'pointerup').pipe(
              Stream.map(() => Message.ReleasedDragPointer()),
            ),
          )

          const documentDragStyles = Stream.callback(() =>
            Effect.acquireRelease(
              Effect.sync(() => {
                document.documentElement.style.setProperty('user-select', 'none')
                document.documentElement.style.setProperty('-webkit-user-select', 'none')
                const cursorStyle = document.createElement('style')
                cursorStyle.textContent = '* { cursor: grabbing !important; }'
                document.head.appendChild(cursorStyle)
                return cursorStyle
              }),
              (cursorStyle) =>
                Effect.sync(() => {
                  document.documentElement.style.removeProperty('user-select')
                  document.documentElement.style.removeProperty('-webkit-user-select')
                  cursorStyle.remove()
                }),
            ).pipe(Effect.flatMap(() => Effect.never)),
          )

          // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Stream.when widens the element type
          return Stream.when(
            Stream.merge(pointerEvents, documentDragStyles),
            Effect.sync(() => dragActivity === 'Active'),
          ) as Stream.Stream<Message>
        },
      },
    ),
    dragEscape: entry(
      { dragActivity: DragActivity },
      {
        modelToDependencies: (model) => ({
          dragActivity: dragActivityFromModel(model),
        }),
        dependenciesToStream: ({ dragActivity }): Stream.Stream<Message> =>
          // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Stream.when widens the element type
          Stream.when(
            Stream.fromEventListener(document, 'keydown').pipe(
              Stream.filter(
                (event): event is KeyboardEvent =>
                  event instanceof KeyboardEvent && event.key === 'Escape',
              ),
              Stream.map(() => Message.CancelledDrag()),
            ),
            Effect.sync(() => dragActivity === 'Active'),
          ) as Stream.Stream<Message>,
      },
    ),
  }))

export const subscriptions = subscriptionsForRoot(() => document)

type Orientation = 'horizontal' | 'vertical'

const filledTrackStyle = (
  orientation: Orientation,
  fraction: number,
): Readonly<Record<string, string>> => {
  if (orientation === 'vertical') {
    // oxlint-disable-next-line anti-slop/no-known-value-widening -- SAFETY: style bag must be Record<string,string> for h.Style; literal evidence is intentionally widened to the style contract
    return {
      position: 'absolute',
      bottom: '0',
      left: '0',
      right: '0',
      height: percentString(fraction),
      width: '100%',
      'pointer-events': 'none',
    }
  }
  // oxlint-disable-next-line anti-slop/no-known-value-widening -- SAFETY: style bag must be Record<string,string> for h.Style; literal evidence is intentionally widened to the style contract
  return {
    position: 'absolute',
    left: '0',
    top: '0',
    bottom: '0',
    width: `calc((100% - ${THUMB_SIZE}) * ${fraction} + ${THUMB_HALF})`,
    'pointer-events': 'none',
  }
}

const thumbStyle = (
  orientation: Orientation,
  fraction: number,
): Readonly<Record<string, string>> => {
  if (orientation === 'vertical') {
    // oxlint-disable-next-line anti-slop/no-known-value-widening -- SAFETY: style bag must be Record<string,string> for h.Style; literal evidence is intentionally widened to the style contract
    return {
      position: 'absolute',
      bottom: percentString(fraction),
      left: '50%',
      transform: 'translateX(-50%) translateY(-50%)',
      'touch-action': 'none',
    }
  }
  // oxlint-disable-next-line anti-slop/no-known-value-widening -- SAFETY: style bag must be Record<string,string> for h.Style; literal evidence is intentionally widened to the style contract
  return {
    position: 'absolute',
    left: `calc((100% - ${THUMB_SIZE}) * ${fraction})`,
    'touch-action': 'none',
  }
}

/** Per-render view inputs passed to `view` via `h.submodel`'s `viewInputs` field. */
export type ViewInputs = Readonly<{
  value: number
  toView: (attributes: SliderAttributes) => Html
  orientation?: Orientation
  ariaLabel?: string
  ariaLabelledBy?: string
  formatValue?: (value: number) => string
  isDisabled?: boolean
  isReadOnly?: boolean
  name?: string
  getTrackRoot?: () => Document | ShadowRoot
}>

/** Renders an accessible slider with upstream edge-aligned geometry and
 *  vertical orientation support, delegating shadcn layout to `toView`. */
export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h) => {
  const {
    value,
    formatValue,
    isDisabled = false,
    isReadOnly = false,
    name,
    orientation = 'horizontal',
    getTrackRoot = () => document,
  } = viewInputs
  const { id, min, max } = model
  const isDragging = model.dragState._tag === 'Dragging'
  const fraction = fractionOfValue(value, min, max)
  const isInteractive = !isDisabled && !isReadOnly

  const handleKeyDown = (key: string) =>
    Option.map(keyToDirection(key), (direction) =>
      Message.PressedKeyboardNavigation({ direction, value }),
    )

  const pointerAtPointer = (clientX: number, clientY: number) =>
    Option.flatMap(trackElement(id, getTrackRoot()), (element) =>
      Option.some(
        Message.PressedPointer({
          value: valueFromPointer(clientX, clientY, element, min, max),
          originValue: value,
        }),
      ),
    )

  const trackPointerHandler = (
    _pointerType: string,
    button: number,
    _screenX: number,
    _screenY: number,
    _timeStamp: number,
    clientX: number,
    clientY: number,
  ) =>
    pipe(
      button,
      Option.liftPredicate(Equal.equals(LEFT_MOUSE_BUTTON)),
      Option.flatMap(() => pointerAtPointer(clientX, clientY)),
    )

  const thumbPointerHandler = (_pointerType: string, button: number) =>
    pipe(
      button,
      Option.liftPredicate(Equal.equals(LEFT_MOUSE_BUTTON)),
      Option.map(() => Message.PressedThumb({ originValue: value })),
    )

  const stateAttributes = [
    ...(isDragging ? [h.DataAttribute('dragging', '')] : []),
    ...(isDisabled ? [h.DataAttribute('disabled', '')] : []),
    ...(isReadOnly ? [h.DataAttribute('readonly', '')] : []),
  ]

  const rootAttributes = [
    h.DataAttribute('slider-id', id),
    h.DataAttribute('orientation', orientation),
    h.DataAttribute(orientation, ''),
    ...stateAttributes,
  ]

  const trackInteractionAttributes = isInteractive ? [h.OnPointerDown(trackPointerHandler)] : []

  const trackAttributes = [
    h.DataAttribute('slider-track-id', id),
    h.DataAttribute('orientation', orientation),
    h.DataAttribute(orientation, ''),
    h.Style({ position: 'relative', 'touch-action': 'none' }),
    ...stateAttributes,
    ...trackInteractionAttributes,
  ]

  const filledTrackAttributes = [
    h.Style(filledTrackStyle(orientation, fraction)),
    ...stateAttributes,
  ]

  const thumbLabelAttributes =
    viewInputs.ariaLabel !== undefined
      ? [h.AriaLabel(viewInputs.ariaLabel)]
      : viewInputs.ariaLabelledBy !== undefined
        ? [h.AriaLabelledBy(viewInputs.ariaLabelledBy)]
        : [h.AriaLabelledBy(labelId(id))]

  const maybeAriaValuetext = formatValue !== undefined ? [h.AriaValuetext(formatValue(value))] : []

  const thumbInteractionAttributes = isInteractive
    ? [h.OnPointerDown(thumbPointerHandler), h.OnKeyDownPreventDefault(handleKeyDown)]
    : []

  const thumbAttributes = [
    h.Id(`${id}-thumb`),
    h.Role('slider'),
    h.Tabindex(0),
    h.AriaOrientation(orientation),
    h.AriaValuemin(min),
    h.AriaValuemax(max),
    h.AriaValuenow(value),
    ...maybeAriaValuetext,
    ...thumbLabelAttributes,
    ...(isDisabled ? [h.AriaDisabled(true)] : []),
    ...(isReadOnly ? [h.AriaReadonly(true)] : []),
    h.Style(thumbStyle(orientation, fraction)),
    ...stateAttributes,
    ...thumbInteractionAttributes,
  ]

  const labelAttributes = [h.Id(labelId(id))]

  const hiddenInputAttributes =
    name !== undefined ? [h.Type('hidden'), h.Name(name), h.Value(value.toString())] : []

  return viewInputs.toView({
    root: childAttributes(rootAttributes),
    track: childAttributes(trackAttributes),
    filledTrack: childAttributes(filledTrackAttributes),
    thumb: childAttributes(thumbAttributes),
    label: childAttributes(labelAttributes),
    hiddenInput: childAttributes(hiddenInputAttributes),
  })
})

export type StyledViewInputs = Readonly<{
  value: number
  min?: number
  max?: number
  orientation?: Orientation
  label?: string
  formatValue?: (value: number) => string
  ariaLabel?: string
  ariaLabelledBy?: string
  isDisabled?: boolean
  isReadOnly?: boolean
  name?: string
  getTrackRoot?: () => Document | ShadowRoot
  rootClass?: string
  trackClass?: string
  filledTrackClass?: string
  thumbClass?: string
  rowClass?: string
  labelClass?: string
  valueClass?: string
  headerClass?: string
}>

/** Build styled `Slider.ViewInputs`. Pass your view's `h`. */
export const styledViewInputs = <M>(
  viewInputs: StyledViewInputs,
  h: HtmlBuilder<M>,
): ViewInputs => {
  const orientation = viewInputs.orientation ?? 'horizontal'

  return {
    value: viewInputs.value,
    orientation,
    ariaLabel: viewInputs.ariaLabel,
    ariaLabelledBy: viewInputs.ariaLabelledBy,
    formatValue: viewInputs.formatValue,
    isDisabled: viewInputs.isDisabled,
    isReadOnly: viewInputs.isReadOnly,
    name: viewInputs.name,
    getTrackRoot: viewInputs.getTrackRoot,
    toView: (attributes): Html => {
      const maybeHeader: Html =
        viewInputs.label === undefined
          ? h.empty
          : h.div(
              [h.Class(cn(sliderHeaderClass, viewInputs.headerClass))],
              [
                h.label(
                  [...attributes.label, h.Class(cn(sliderLabelClass, viewInputs.labelClass))],
                  [viewInputs.label],
                ),
                h.span(
                  [h.Class(cn(sliderValueClass, viewInputs.valueClass))],
                  [
                    viewInputs.formatValue === undefined
                      ? String(viewInputs.value)
                      : viewInputs.formatValue(viewInputs.value),
                  ],
                ),
              ],
            )

      const maybeHiddenInput: Html =
        attributes.hiddenInput.length > 0 ? h.input([...attributes.hiddenInput]) : h.empty

      return h.div(
        [h.Class(cn(sliderRowClass, viewInputs.rowClass))],
        [
          maybeHeader,
          h.div(
            [
              ...attributes.root,
              h.DataAttribute('slot', 'slider'),
              h.Class(cn(sliderRootClass, viewInputs.rootClass)),
            ],
            [
              h.div(
                [
                  ...attributes.track,
                  h.DataAttribute('slot', 'slider-track'),
                  h.Class(cn(sliderTrackClass, viewInputs.trackClass)),
                ],
                [
                  h.div([
                    ...attributes.filledTrack,
                    h.DataAttribute('slot', 'slider-range'),
                    h.Class(cn(sliderFilledTrackClass, viewInputs.filledTrackClass)),
                  ]),
                ],
              ),
              h.div([
                ...attributes.thumb,
                h.DataAttribute('slot', 'slider-thumb'),
                h.Class(cn(sliderThumbClass, viewInputs.thumbClass)),
              ]),
            ],
          ),
          maybeHiddenInput,
        ],
      )
    },
  }
}