Button

The standard action, in six variants and four sizes, with a loading state. Renders as any tag or component.

Install

If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.

npx ermineui init
pnpm dlx ermineui init
yarn dlx ermineui init
bunx ermineui init

Then add the component:

npx ermineui add button
pnpm dlx ermineui add button
yarn dlx ermineui add button
bunx ermineui add button

That copies 2 files into src/components/ui/button/, 1 helper into src/lib/ermine/, and the 1 component it is built from. Files you already have are kept.

Use

import { Button } from '@/components/ui/button'
<Button><Icon name="plus" /> New project</Button>
<Button size="icon" aria-label="Delete"><Icon name="trash" /></Button>
<Icon name="circle-check" label="Passed" :size="20" />

In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.

What gets copied

FileWhere it goes
Button.vuesrc/components/ui/button/Button.vue
index.tssrc/components/ui/button/index.ts
primitive.ts helpersrc/lib/ermine/primitive.ts

It is built from: Spinner. These are added with it.

The folders are the suggested ones. ermine.json says where yours are.

Source

This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.

Button.vue 163 lines
<script setup lang="ts">
import type { Component } from 'vue'
import { Primitive } from '../../lib/primitive'
import { Spinner } from '../spinner'

withDefaults(
  defineProps<{
    variant?: 'default' | 'secondary' | 'outline' | 'ghost' | 'destructive' | 'link'
    size?: 'sm' | 'md' | 'lg' | 'icon'
    /** Tag or component to render, e.g. "a" or NuxtLink. */
    as?: string | Component
    /** Pass the button's styling and behavior to the single child instead of rendering a tag. */
    asChild?: boolean
    disabled?: boolean
    /** Set while the action is running: shows a spinner and stops the button being pressed again. */
    loading?: boolean
  }>(),
  { variant: 'default', size: 'md', as: 'button' },
)
</script>

<template>
  <Primitive
    :as="as"
    :as-child="asChild"
    :type="as === 'button' && !asChild ? 'button' : undefined"
    class="er-button"
    :data-variant="variant"
    :data-size="size"
    :disabled="(as === 'button' || asChild) && (disabled || loading) ? true : undefined"
    :aria-disabled="as !== 'button' && (disabled || loading) ? true : undefined"
    :aria-busy="loading || undefined"
  >
    <!-- With as-child the content belongs to the child, so there is nowhere to put a spinner. -->
    <Spinner v-if="loading && !asChild" label="" />
    <!-- An icon button has room for one thing, so the spinner stands in for the icon. -->
    <slot v-if="!loading || asChild || size !== 'icon'" />
  </Primitive>
</template>

<style>
@layer components {
  .er-button {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 0.5rem;
    height: var(--er-control-md);
    padding: 0 1rem;
    border: 1px solid transparent;
    border-radius: var(--er-radius-control);
    background: var(--er-primary);
    color: var(--er-primary-foreground);
    font-family: inherit;
    font-size: 0.875rem;
    font-weight: var(--er-weight-medium);
    line-height: 1;
    white-space: nowrap;
    text-decoration: none;
    cursor: pointer;
    user-select: none;
    transition:
      background-color var(--er-duration),
      color var(--er-duration),
      border-color var(--er-duration),
      box-shadow var(--er-duration);
  }

  .er-button:hover {
    background: color-mix(in oklab, var(--er-primary) 88%, var(--er-background));
  }

  .er-button:focus-visible {
    outline: 2px solid var(--er-ring);
    outline-offset: 2px;
  }

  .er-button:disabled,
  .er-button[aria-disabled='true'] {
    opacity: 0.5;
    pointer-events: none;
  }

  .er-button svg {
    flex-shrink: 0;
    width: 1rem;
    height: 1rem;
    pointer-events: none;
  }

  /* Variants */
  .er-button[data-variant='secondary'] {
    background: var(--er-secondary);
    color: var(--er-secondary-foreground);
  }

  .er-button[data-variant='secondary']:hover {
    background: color-mix(in oklab, var(--er-secondary) 80%, var(--er-foreground) 6%);
  }

  .er-button[data-variant='outline'] {
    border-color: var(--er-input);
    background: var(--er-input-background);
    color: var(--er-foreground);
    box-shadow: var(--er-shadow-xs);
  }

  .er-button[data-variant='outline']:hover,
  .er-button[data-variant='ghost']:hover,
  .er-button[data-variant='outline'][data-state='open'],
  .er-button[data-variant='ghost'][data-state='open'] {
    background: var(--er-accent);
    color: var(--er-accent-foreground);
  }

  .er-button[data-variant='ghost'] {
    background: transparent;
    color: var(--er-foreground);
  }

  .er-button[data-variant='destructive'] {
    background: var(--er-destructive);
    color: var(--er-destructive-foreground);
  }

  .er-button[data-variant='destructive']:hover {
    background: color-mix(in oklab, var(--er-destructive) 88%, var(--er-background));
  }

  .er-button[data-variant='link'] {
    height: auto;
    padding: 0;
    background: transparent;
    color: var(--er-foreground);
    text-decoration: underline;
    text-decoration-color: transparent;
    text-underline-offset: 4px;
  }

  .er-button[data-variant='link']:hover {
    text-decoration-color: currentColor;
  }

  /* Sizes */
  .er-button[data-size='sm'] {
    gap: 0.375rem;
    height: var(--er-control-sm);
    padding: 0 0.75rem;
    font-size: 0.8125rem;
  }

  .er-button[data-size='lg'] {
    height: var(--er-control-lg);
    padding: 0 1.5rem;
  }

  .er-button[data-size='icon'] {
    width: var(--er-control-md);
    padding: 0;
  }
}
</style>
index.ts 3 lines
/** Button — the standard action, in six variants and four sizes, with a loading state. Renders as any tag or component. */
export { default as Button } from './Button.vue'