<script setup lang="ts">
import { Send } from '@lucide/vue'
import { Button, Inline } from '@hina-ui/vue'
</script>
<template>
<Inline>
<Button variant="ghost" tone="neutral">Cancel</Button>
<Button variant="outline" tone="neutral">Save draft</Button>
<Button>
<template #icon>
<Send />
</template>
Publish
</Button>
</Inline>
</template>
Usage
import { Button } from '@hina-ui/vue'
Give one screen a single primary action, put the rest in lighter variants, and use the neutral tone for cancelling.
<script setup lang="ts">
import { Button, Inline } from '@hina-ui/vue'
</script>
<template>
<Inline>
<Button>Save</Button>
<Button variant="outline" tone="neutral">Cancel</Button>
</Inline>
</template>
Examples
Variants
Five visual styles, growing lighter from solid to plain text. link keeps only the text and an underline, with no background and no press effect.
<template>
<Button>Button</Button>
</template>Tones
Use accent for the primary action, neutral for secondary actions and cancelling, and danger for anything that cannot be undone. Tone and variant are independent.
<template>
<Button>Confirm</Button>
</template>Sizes
Four sizes, whose height and padding scale with density.
<template>
<Button>Button</Button>
</template>Density
Density is declared on a container and applies to every control inside it. The top row is the default density, the bottom row the compact one.
<script setup lang="ts">
import { Button, Inline, Stack } from '@hina-ui/vue'
</script>
<template>
<Stack>
<Inline>
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
</Inline>
<Inline data-density="compact">
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
</Inline>
</Stack>
</template>
Icons
#icon holds a leading icon and #trailing a trailing one.
<script setup lang="ts">
import { ArrowRight, Plus, Settings } from '@lucide/vue'
import { Button, IconButton, Inline } from '@hina-ui/vue'
</script>
<template>
<Inline>
<Button>
<template #icon>
<Plus />
</template>
New
</Button>
<Button variant="outline" tone="neutral">
Next
<template #trailing>
<ArrowRight />
</template>
</Button>
<IconButton label="Settings" variant="outline">
<Settings />
</IconButton>
</Inline>
</template>
Icon only
Use IconButton when a button holds only an icon. That component requires label, which serves as both the accessible name and the tooltip text.
<script setup lang="ts">
import { Bookmark, Check, Share2, Trash2 } from '@lucide/vue'
import { IconButton, Inline } from '@hina-ui/vue'
</script>
<template>
<Inline>
<IconButton label="Bookmark">
<Bookmark />
</IconButton>
<IconButton label="Share" variant="outline">
<Share2 />
</IconButton>
<IconButton label="Confirm" variant="solid" tone="accent">
<Check />
</IconButton>
<IconButton label="Delete" variant="soft" tone="danger">
<Trash2 />
</IconButton>
</Inline>
</template>
Button groups
ButtonGroup joins several buttons into one unit, merging the corners and borders where they meet. divider adds a rule between adjacent buttons.
<script setup lang="ts">
import { AlignCenter, AlignLeft, AlignRight } from '@lucide/vue'
import { Button, ButtonGroup, IconButton, Stack } from '@hina-ui/vue'
</script>
<template>
<Stack align="center">
<ButtonGroup label="Alignment">
<IconButton label="Align left" variant="outline" tone="neutral">
<AlignLeft />
</IconButton>
<IconButton label="Align center" variant="outline" tone="neutral">
<AlignCenter />
</IconButton>
<IconButton label="Align right" variant="outline" tone="neutral">
<AlignRight />
</IconButton>
</ButtonGroup>
<ButtonGroup label="View" divider>
<Button variant="soft" tone="neutral">List</Button>
<Button variant="soft" tone="neutral">Grid</Button>
<Button variant="soft" tone="neutral">Timeline</Button>
</ButtonGroup>
</Stack>
</template>
States
With loading the button shows a spinner and refuses clicks; with disabled it is unavailable.
<template>
<Button>Save</Button>
</template>The spinner replaces the leading icon first; with no leading icon it replaces the trailing one; with neither it is centred over the label. The label then turns transparent but stays in place, so the width of the button does not change.
<script setup lang="ts">
import { ArrowRight, Save } from '@lucide/vue'
import { Button, Inline } from '@hina-ui/vue'
</script>
<template>
<Inline>
<Button loading>Save</Button>
<Button loading variant="soft">
<template #icon>
<Save />
</template>
Save
</Button>
<Button loading variant="outline" tone="neutral">
Next
<template #trailing>
<ArrowRight />
</template>
</Button>
</Inline>
</template>
Submitting
A click moves the button into the loading state, during which further clicks are ignored and the label changes with it.
<script setup lang="ts">
import { ref } from 'vue'
import { Upload } from '@lucide/vue'
import { Button } from '@hina-ui/vue'
const uploading = ref(false)
function upload() {
uploading.value = true
setTimeout(() => (uploading.value = false), 2000)
}
</script>
<template>
<Button :loading="uploading" @click="upload">
<template #icon>
<Upload />
</template>
{{ uploading ? 'Uploading…' : 'Upload file' }}
</Button>
</template>
Signing in with a provider
block makes the button fill the width of its container, with the brand icon in the leading slot.
<script setup lang="ts">
import appleSvg from '@lobehub/icons-static-svg/icons/apple.svg?raw'
import githubSvg from '@lobehub/icons-static-svg/icons/github.svg?raw'
import googleSvg from '@lobehub/icons-static-svg/icons/google-color.svg?raw'
import { Button, Stack } from '@hina-ui/vue'
import RawIcon from '~/components/docs/RawIcon'
const providers = [
{ svg: googleSvg, label: 'Sign in with Google' },
{ svg: githubSvg, label: 'Sign in with GitHub' },
{ svg: appleSvg, label: 'Sign in with Apple' },
]
</script>
<template>
<Stack gap="sm" class="w-full max-w-xs">
<Button
v-for="provider in providers"
:key="provider.label"
variant="soft"
tone="neutral"
block
pill
>
<template #icon>
<RawIcon :svg="provider.svg" />
</template>
{{ provider.label }}
</Button>
</Stack>
</template>
Shape and width
pill gives the button rounded ends and block makes it fill the width of its container.
<template>
<Button>Continue</Button>
</template>When the button’s width is limited (for example with max-w-full), a <Text truncate> in the default slot is cut off with an ellipsis inside the button.
As a link
as renders the button as an a element or as NuxtLink, with the same appearance and interaction.
<script setup lang="ts">
import { ArrowUpRight } from '@lucide/vue'
import { Button, Inline } from '@hina-ui/vue'
</script>
<template>
<Inline>
<Button
as="a"
href="https://www.hikarinagi.com"
target="_blank"
rel="noreferrer"
variant="outline"
>
Visit Hikarinagi
<template #trailing>
<ArrowUpRight />
</template>
</Button>
<Button
as="a"
href="https://www.hikarinagi.com"
target="_blank"
rel="noreferrer"
variant="link"
>
Learn more
</Button>
</Inline>
</template>
Where only the appearance of a link is wanted, without the semantics of a button, use the Link component rather than variant="link".
If the target component renders its own root element, use asChild instead: the button renders nothing itself and merges its classes and behaviour into the single child.
In this mode the child owns its content, including icons, ripples and loading indicators. loading still sets the busy state and blocks clicks.
<template>
<Button as-child>
<NuxtLink to="/guide/installation">Get started</NuxtLink>
</Button>
</template>
Custom styles
Tailwind classes
Whatever is passed as class goes through tailwind-merge, where the later declaration of the same property wins, so !important is unnecessary.
<script setup lang="ts">
import { Button, Inline } from '@hina-ui/vue'
</script>
<template>
<Inline>
<Button class="rounded-none px-8">Square</Button>
<Button variant="outline" tone="neutral" class="border-dashed">Dashed</Button>
<Button class="bg-purple-600 text-white hover:bg-purple-700">Purple</Button>
</Inline>
</template>
Global overrides
The colours, corner radius and focus ring of a button all come from semantic variables. Redeclaring those variables in any scope changes the appearance of the buttons inside it.
.brand-purple {
--hn-accent: oklch(0.55 0.22 300);
--hn-accent-on: #ffffff;
--hn-focus-ring: oklch(0.5 0.22 300);
}
Style reference
Size variables
| Variable | Default | Compact |
|---|---|---|
--hn-control-h-xs | 1.5rem | 1.25rem |
--hn-control-h-sm | 1.75rem | 1.5rem |
--hn-control-h-md | 2.25rem | 1.875rem |
--hn-control-h-lg | 2.75rem | 2.25rem |
--hn-control-px-xs | 0.5rem | 0.25rem |
--hn-control-px-sm | 0.625rem | 0.375rem |
--hn-control-px-md | 1rem | 0.5rem |
--hn-control-px-lg | 1.25rem | 0.75rem |
--hn-control-gap | 0.375rem | 0.25rem |
The compact density suits dense interfaces such as admin tables. Density does not follow the device type, so if a touch screen needs larger hit targets, use the default density explicitly.
State attributes
When rendered as a native button, the native disabled is used directly. Other elements have no native disabled semantics, so the following attributes carry the state instead.
| Attribute | Meaning |
|---|---|
data-loading | Loading |
aria-busy | Loading, announced by screen readers |
data-disabled | Unavailable |
aria-disabled | Unavailable, announced by screen readers |
tabindex="-1" | Unavailable, skipped by keyboard focus |
A loading button counts as unavailable.
Hover and press
- The focus ring appears on keyboard focus only; a mouse click leaves no outline.
- Hover and press add a translucent layer rather than replacing the background, and touch devices leave no lingering hover state.
- The ripple spreads from the point of contact, and the press layer no longer stacks on top of it. The
linkvariant has no ripple.
Accessibility
- An icon-only button must have an accessible name; without one, a warning is printed in development. Prefer
IconButtondirectly. - A loading button carries
aria-busy, which screen readers announce as busy.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
as | string | Component | 'button' | Element or component to render |
asChild | boolean | false | Render nothing, merging into the single child |
variant | 'solid' | 'soft' | 'outline' | 'ghost' | 'link' | 'solid' | Visual style |
tone | 'accent' | 'neutral' | 'danger' | 'accent' | Semantic tone |
size | 'xs' | 'sm' | 'md' | 'lg' | 'md' | Size |
type | 'button' | 'submit' | 'reset' | 'button' | Native button type |
iconOnly | boolean | false | Square button holding only an icon |
block | boolean | false | Fill the width of the container |
pill | boolean | false | Render with rounded ends |
loading | boolean | false | Whether it is loading |
disabled | boolean | false | Whether it is disabled |
ripple | boolean | true | Enable the press ripple; no effect on link |
class | string | — | Classes appended to the root |
Slots
| Slot | Description |
|---|---|
default | The button content; multiple children are spaced apart |
icon | Leading icon, replaced by the spinner while loading |
trailing | Trailing icon, replaced only when there is no leading one |