Skip to contentVibraUI
Inputs & filters

Toggle row

A settings row pairing a label and hint with a switch, divided from its neighbours by a hairline.

The label is a real label for the switch, so clicking the text toggles it. Consecutive rows draw one hairline between them and a lone row draws none, so a list of them needs no dividers of its own.

Install

npx shadcn@latest add @vibra/toggle-row

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
labelReact.ReactNode—What the switch controls; also names it.
descriptionReact.ReactNode—One line under the label saying what turning it on does.
checkedboolean—Whether the switch is on.
onCheckedChange(checked: boolean) => void—Called with the new state from the switch or from the label.
disabledbooleanfalseDims the row and stops the switch.
iconReact.ReactNode—Sits before the label; sized to 4 unless it sets its own size.
idstringgeneratedBinds the label to the switch; one is generated when it is left out.

Dependencies

Source

components/ui/toggle-row.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"

export type ToggleRowProps = Omit<React.ComponentProps<"div">, "onChange"> & {
  label: React.ReactNode
  description?: React.ReactNode
  checked: boolean
  onCheckedChange: (checked: boolean) => void
  disabled?: boolean
  /** Sits before the label; sized to 4 unless it sets its own size. */
  icon?: React.ReactNode
  /** Binds the label to the switch; one is generated when it is left out. */
  id?: string
}

/** A settings row that pairs a label and hint with a switch, so clicking the text toggles it. */
function ToggleRow({
  className,
  label,
  description,
  checked,
  onCheckedChange,
  disabled = false,
  icon,
  id,
  ...props
}: ToggleRowProps) {
  const generatedId = React.useId()
  const switchId = id ?? generatedId
  const descriptionId = `${switchId}-description`

  return (
    <div
      data-slot="toggle-row"
      data-checked={checked || undefined}
      data-disabled={disabled || undefined}
      // [&+&] is ".this-class + .this-class": consecutive rows draw one hairline
      // between them, and a lone row draws none.
      className={cn(
        "flex w-full items-start justify-between gap-4 py-3 [&+&]:border-t",
        disabled && "opacity-50",
        className
      )}
      {...props}
    >
      <div className="flex min-w-0 items-start gap-3">
        {icon ? (
          <span
            data-slot="toggle-row-icon"
            aria-hidden="true"
            className="mt-0.5 text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4"
          >
            {icon}
          </span>
        ) : null}
        <div className="flex min-w-0 flex-col gap-1">
          {/* A real <label for>: <button role="switch"> is a labelable element,
              so clicking the text activates the switch itself. */}
          <Label
            htmlFor={switchId}
            className={cn("w-fit", !disabled && "cursor-pointer")}
          >
            {label}
          </Label>
          {description ? (
            <p
              id={descriptionId}
              data-slot="toggle-row-description"
              className="text-xs text-muted-foreground"
            >
              {description}
            </p>
          ) : null}
        </div>
      </div>

      <Switch
        id={switchId}
        // The hint says what turning this on actually does, so it belongs to the
        // switch and not only to the sighted reader.
        aria-describedby={description ? descriptionId : undefined}
        checked={checked}
        disabled={disabled}
        // Base UI hands the handler an event-details second argument; the
        // public signature is just the new state.
        onCheckedChange={(next) => onCheckedChange(next)}
        className="mt-0.5 shrink-0"
      />
    </div>
  )
}

export { ToggleRow }