Skip to main content

ToggleButton

Toggle a persistent state with independently configured off and on button variants.

Use ToggleButton for persistent on/off actions such as bold formatting or bookmarking. It renders a native button and exposes its state through aria-pressed.

Basic usage#

import { ToggleButton } from 'moraine'

export function Example() {
  return (
    <ToggleButton variant="ghost" activeVariant="secondary" leading="i-lucide:bold">
      Bold
    </ToggleButton>
  )
}

Playground#

Props
Slots

Anatomy#

ToggleButton [component; slot=root; <button>]
├── leading [slot]
├── label [slot]
└── trailing [slot]

Usage#

Off and on variants#

variant controls the unpressed appearance; activeVariant controls the pressed appearance. Both accept all Button variants. The defaults are ghost and secondary. Changing a variant does not reset the toggle state. The link variant is a visual treatment: ToggleButton always renders a button with type="button", so it does not navigate or submit a form.

import { ToggleButton } from 'moraine'

export function Variants() {
  return (
    <div class="flex flex-wrap gap-3">
      <ToggleButton>Ghost to secondary</ToggleButton>
      <ToggleButton variant="outline" activeVariant="default">
        Outline to primary
      </ToggleButton>
      <ToggleButton variant="secondary" activeVariant="destructive" defaultPressed>
        Secondary to destructive
      </ToggleButton>
    </div>
  )
}

Controlled and uncontrolled state#

Use defaultPressed to initialize an uncontrolled button. Pass pressed and onPressedChange when application state owns the value. Controlled activation reports the requested next value and waits for the caller to update pressed.

User onClick runs before the toggle. Calling event.preventDefault() cancels both the state change and onPressedChange.

import { Button, ToggleButton } from 'moraine'
import { createSignal } from 'solid-js'

export function Controlled() {
  const [pressed, setPressed] = createSignal(false)
  return (
    <div class="space-y-3">
      <div class="flex flex-wrap gap-3 items-center">
        <ToggleButton pressed={pressed()} onPressedChange={setPressed} leading="i-lucide:pin">
          Pin conversation
        </ToggleButton>
        <Button variant="outline" size="sm" onClick={() => setPressed(false)}>
          Reset
        </Button>
      </div>
      <p class="text-sm text-muted-foreground">
        Conversation: <output aria-live="polite">{pressed() ? 'Pinned' : 'Unpinned'}</output>
      </p>
    </div>
  )
}

Icons and accessible names#

Keep the accessible name stable while toggling. Use aria-label for icon-only buttons; aria-pressed communicates whether their action is enabled. Enter, Space, and clicks activate the native button. Disabled and loading buttons suppress activation.

Children can be a render function receiving reactive pressed and loading values. This supports state-dependent visual content without replacing the button or remounting its children.

import { Icon, ToggleButton } from 'moraine'

export function Icons() {
  return (
    <div class="flex gap-3 items-center">
      <ToggleButton size="icon-md" leading="i-lucide:bold" aria-label="Bold" />
      <ToggleButton size="icon-md" leading="i-lucide:italic" aria-label="Italic" defaultPressed />
      <ToggleButton size="icon-md" aria-label="Bookmark" variant="outline" activeVariant="default">
        {(state) => (
          <Icon
            name="i-lucide:bookmark"
            class={state.pressed ? 'fill-current' : undefined}
            aria-hidden="true"
          />
        )}
      </ToggleButton>
    </div>
  )
}

Async actions#

loadingAuto follows a Promise returned by onClick and blocks repeated activation until it settles. The state changes immediately after an uncancelled click, including when the Promise later rejects; it is not automatically rolled back. Handle request errors in your async handler. To update only after success, use controlled pressed and update it after the request completes.

Theme and groups#

Configure toggleButton.defaultVariants to choose the default off and on appearances and size. Button’s theme still supplies the underlying variant styles; toggleButton.base, classes, and styles add component-specific presentation. Instance styling follows the standard root and slot override rules.

Inside ButtonGroup, ToggleButton inherits the group’s size and unpressed variant unless explicitly supplied. Its pressed variant comes from its own configuration. ButtonGroup provides layout, not single or multiple selection behavior.

The root uses data-slot="toggle-button" and exposes data-selected while pressed. The leading, label, and trailing elements retain the corresponding button-* data-slot names.

import { ButtonGroup, MoraineProvider, ToggleButton } from 'moraine'
import { defineTheme } from 'moraine/theme'

const theme = defineTheme({
  toggleButton: {
    defaultVariants: { variant: 'outline', activeVariant: 'default', size: 'sm' },
  },
})

export function Theme() {
  return (
    <MoraineProvider theme={theme}>
      <div class="flex flex-wrap gap-4 items-center">
        <ToggleButton leading="i-lucide:bell">Notifications</ToggleButton>
        <ToggleButton leading="i-lucide:mail" activeVariant="secondary" defaultPressed>
          Email updates
        </ToggleButton>
        <ButtonGroup size="sm" variant="ghost" aria-label="Text formatting">
          <ToggleButton leading="i-lucide:bold" aria-label="Bold formatting" />
          <ToggleButton leading="i-lucide:italic" aria-label="Italic formatting" />
        </ButtonGroup>
      </div>
    </MoraineProvider>
  )
}

Attributes#

Attributes
data-selectedSlot: toggle-buttonDescription: Present when the item or tab is selected.

Props#

Prop