Toggle

A two-state button that stays pressed.

<script setup lang="ts">
  import { ref } from 'vue'
  import { Bold, Italic, Underline } from '@lucide/vue'
  import { Inline, Toggle } from '@hina-ui/vue'

  const bold = ref(true)
  const italic = ref(false)
  const underline = ref(false)
</script>

<template>
  <Inline gap="xs">
    <Toggle v-model="bold" label="Bold">
      <template #icon><Bold /></template>
    </Toggle>
    <Toggle v-model="italic" label="Italic">
      <template #icon><Italic /></template>
    </Toggle>
    <Toggle v-model="underline" label="Underline">
      <template #icon><Underline /></template>
    </Toggle>
  </Inline>
</template>
vue

Usage

import { Toggle } from '@hina-ui/vue'
ts

A toggle is a button that can stay pressed, for actions that can be switched on or off at any time such as bold, favorite or a filter. v-model binds whether it is pressed, the default slot is the text and #icon takes a leading icon. It borrows the ghost and outline looks of Button; when pressed, text and ink turn to the accent color. Attributes it does not declare land on the button.

Showing all works

<script setup lang="ts">
  import { ref } from 'vue'
  import { ListFilter } from '@lucide/vue'
  import { Stack, Text, Toggle } from '@hina-ui/vue'

  const onlyDone = ref(false)
</script>

<template>
  <Stack gap="sm" align="start">
    <Toggle v-model="onlyDone">
      <template #icon><ListFilter /></template>
      Completed only
    </Toggle>
    <Text tone="muted" size="sm">
      {{ onlyDone ? 'Showing completed works' : 'Showing all works' }}
    </Text>
  </Stack>
</template>
vue

It differs from Switch by occasion: a switch stands for a setting, always carries a text and sits on its own row; a toggle is an action, often icon-only, lined up in a toolbar. Compared with a selectable Chip, a chip is an inline item, pill-shaped and smaller.

Examples

Icon only

label supplies the accessible name. Without a default text slot, the button uses square, icon-only sizing; with text, it keeps normal button padding. Inside a TooltipProvider, the label also appears in a Tooltip. #pressed-icon supplies the icon for the pressed state; the two icons cross-fade.

<script setup lang="ts">
  import { ref } from 'vue'
  import { Bookmark, Star } from '@lucide/vue'
  import { Inline, Toggle } from '@hina-ui/vue'

  const starred = ref(true)
  const saved = ref(false)
</script>

<template>
  <Inline gap="xs">
    <Toggle v-model="starred" label="Favorite" pill>
      <template #icon><Star /></template>
      <template #pressed-icon><Star fill="currentColor" /></template>
    </Toggle>
    <Toggle v-model="saved" label="Watch later" pill>
      <template #icon><Bookmark /></template>
      <template #pressed-icon><Bookmark fill="currentColor" /></template>
    </Toggle>
  </Inline>
</template>
vue

Toolbar

Icon toggles in a row each hold their own boolean and do not affect each other. When exactly one must be selected, use SegmentedControl.

<script setup lang="ts">
  import { reactive } from 'vue'
  import { Bold, Italic, Strikethrough, Underline } from '@lucide/vue'
  import { Inline, Toggle } from '@hina-ui/vue'

  const marks = reactive({ bold: false, italic: false, underline: false, strike: false })
</script>

<template>
  <Inline gap="xs">
    <Toggle v-model="marks.bold" label="Bold" size="sm">
      <template #icon><Bold /></template>
    </Toggle>
    <Toggle v-model="marks.italic" label="Italic" size="sm">
      <template #icon><Italic /></template>
    </Toggle>
    <Toggle v-model="marks.underline" label="Underline" size="sm">
      <template #icon><Underline /></template>
    </Toggle>
    <Toggle v-model="marks.strike" label="Strikethrough" size="sm">
      <template #icon><Strikethrough /></template>
    </Toggle>
  </Inline>
</template>
vue

Variants

ghost is the default; outline adds a hairline border and suits a toggle standing alone in content.

<script setup lang="ts">
  import { ref } from 'vue'
  import { Pin } from '@lucide/vue'
  import { Inline, Toggle } from '@hina-ui/vue'

  const ghost = ref(true)
  const outline = ref(true)
</script>

<template>
  <Inline gap="sm">
    <Toggle v-model="ghost" variant="ghost">
      <template #icon><Pin /></template>
      Pinned
    </Toggle>
    <Toggle v-model="outline" variant="outline">
      <template #icon><Pin /></template>
      Pinned
    </Toggle>
  </Inline>
</template>
vue

Sizes

size has three steps, sm, md and lg, matching Button step by step.

<script setup lang="ts">
  import { Heart } from '@lucide/vue'
  import { Inline, Toggle } from '@hina-ui/vue'
</script>

<template>
  <Inline gap="sm" align="center">
    <Toggle size="sm" variant="outline" model-value>
      <template #icon><Heart /></template>
      Small
    </Toggle>
    <Toggle size="md" variant="outline" model-value>
      <template #icon><Heart /></template>
      Medium
    </Toggle>
    <Toggle size="lg" variant="outline" model-value>
      <template #icon><Heart /></template>
      Large
    </Toggle>
  </Inline>
</template>
vue

States

disabled disables the button and keeps its pressed state.

<script setup lang="ts">
  import { Bold } from '@lucide/vue'
  import { Inline, Toggle } from '@hina-ui/vue'
</script>

<template>
  <Inline gap="sm">
    <Toggle variant="outline" disabled>
      <template #icon><Bold /></template>
      Disabled
    </Toggle>
    <Toggle variant="outline" disabled model-value>
      <template #icon><Bold /></template>
      Disabled and pressed
    </Toggle>
  </Inline>
</template>
vue

In a form

Inside a FormField the error message is rendered by the field and linked to the button; validation rules and submission belong to the Form. The toggle carries its own text, so the field needs no label; when its value decides whether another field is required, the rule goes on the object and names the field the error lands on.

<script setup lang="ts">
  import { reactive, ref } from 'vue'
  import * as v from 'valibot'
  import { Button, Form, FormField, Input, Text, Toggle } from '@hina-ui/vue'

  const schema = v.pipe(
    v.object({
      pinned: v.boolean(),
      title: v.string(),
    }),
    v.forward(
      v.check(
        input => !input.pinned || input.title.trim().length > 0,
        'A pinned announcement needs a title',
      ),
      ['title'],
    ),
  )

  const values = reactive({ pinned: true, title: '' })
  const saved = ref('')

  async function save(data: unknown) {
    await new Promise(resolve => setTimeout(resolve, 600))
    saved.value = JSON.stringify(data)
  }
</script>

<template>
  <Form v-slot="{ submitting }" :values="values" :rules="schema" class="w-80" @submit="save">
    <FormField name="title" label="Title">
      <Input v-model="values.title" />
    </FormField>
    <FormField name="pinned">
      <Toggle v-model="values.pinned" variant="outline">Pin</Toggle>
    </FormField>
    <Button type="submit" :loading="submitting" class="self-start">Publish</Button>
    <Text v-if="saved" tone="muted" size="sm">Published: {{ saved }}</Text>
  </Form>
</template>
vue

Behavior

  • Click, Space or Enter switches between pressed and released.
  • The pressed state is expressed by accent ink and text color; hover and press ink stack on top.
  • With #pressed-icon, the two icons cross-fade instead of cutting.

Accessibility

  • The root is a button with aria-pressed; screen readers announce it as a toggle button and its state.
  • Icon-only toggles must provide label; text toggles use their text by default and can supply a fuller name through label.

API

Props

Prop
Type
Default
Description
modelValue
boolean
false
Whether it is pressed
label
string
—
Accessible name; uses icon-only sizing when no text slot is present
tooltip
boolean
true
Whether to show a tooltip for the label
side
'top' | 'right' | 'bottom' | 'left'
'top'
Tooltip placement
variant
'ghost' | 'outline'
'ghost'
Variant
size
'sm' | 'md' | 'lg'
'md'
Size
pill
boolean
false
Whether it is pill-shaped
disabled
boolean
false
Whether it is disabled
ripple
boolean
true
Whether to show the press ripple
class
string
—
Classes appended to the button element

Slots

Slot
Payload
Description
default
—
Text
icon
{ pressed: boolean }
Leading icon
pressed-icon
—
Leading icon while pressed

Events

Event
Payload
Description
update:modelValue
value: boolean
The pressed state changed