Button

Triggers a single action. Variant sets the visual style, tone sets the meaning.

<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>
vue

Usage

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

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>
vue

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.

variant
tone
<template>
  <Button>Button</Button>
</template>
vue

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.

tone
variant
<template>
  <Button>Confirm</Button>
</template>
vue

Sizes

Four sizes, whose height and padding scale with density.

size
variant
<template>
  <Button>Button</Button>
</template>
vue

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>
vue

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>
vue

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>
vue

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>
vue

States

With loading the button shows a spinner and refuses clicks; with disabled it is unavailable.

loading
disabled
variant
<template>
  <Button>Save</Button>
</template>
vue

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>
vue

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>
vue

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>
vue

Shape and width

pill gives the button rounded ends and block makes it fill the width of its container.

pill
block
size
<template>
  <Button>Continue</Button>
</template>
vue

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.

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>
vue

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);
}
css

Style reference

Size variables

VariableDefaultCompact
--hn-control-h-xs1.5rem1.25rem
--hn-control-h-sm1.75rem1.5rem
--hn-control-h-md2.25rem1.875rem
--hn-control-h-lg2.75rem2.25rem
--hn-control-px-xs0.5rem0.25rem
--hn-control-px-sm0.625rem0.375rem
--hn-control-px-md1rem0.5rem
--hn-control-px-lg1.25rem0.75rem
--hn-control-gap0.375rem0.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.

AttributeMeaning
data-loadingLoading
aria-busyLoading, announced by screen readers
data-disabledUnavailable
aria-disabledUnavailable, 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 link variant has no ripple.

Accessibility

  • An icon-only button must have an accessible name; without one, a warning is printed in development. Prefer IconButton directly.
  • 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