<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>
Usage
import { Toggle } from '@hina-ui/vue'
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>
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>
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>
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>
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>
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>
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>
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 throughlabel.
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 |