Frontile

Button

The Button component can be used to trigger an action, such as submitting a form, opening a modal, and more.

Import

import { Button } from 'frontile';

Usage

import { Button } from 'frontile';

<template>
  <Button>Button</Button>
</template>

Button Variants

import { Button } from 'frontile';

<template>
  <div class='flex flex-wrap items-center gap-3'>
    <Button>Default</Button>
    <Button @variant='subtle'>Subtle</Button>
    <Button @variant='outline'>Outline</Button>
    <Button @variant='soft'>Soft</Button>
    <Button @variant='plain'>Plain</Button>
    <Button @variant='custom'>Custom</Button>
  </div>
</template>

The custom variant is available for the cases where you might want to fully customize the appearance of the button. The default styles are mainly structural. Colors are applied as color.

Button Colors

Every color is available in every variant. The label on each row is the @variant value; the button labels are the @color values.

@variant='solid'

@variant='subtle'

@variant='outline'

@variant='soft'

@variant='plain'

import { Button } from 'frontile';
import { array } from '@ember/helper';

const colors = [
  'neutral',
  'primary',
  'secondary',
  'tertiary',
  'success',
  'warning',
  'danger'
];

<template>
  <div class='flex flex-col gap-6'>
    {{#each (array 'solid' 'subtle' 'outline' 'soft' 'plain') as |variant|}}
      <div>
        <p class='font-code text-code-sm text-neutral-strong mb-2'>
          @variant='{{variant}}'
        </p>
        <div class='flex flex-wrap items-center gap-3'>
          {{#each colors as |color|}}
            <Button @variant={{variant}} @color={{color}}>
              {{color}}
            </Button>
          {{/each}}
        </div>
      </div>
    {{/each}}
  </div>
</template>

Button Sizes

import { Button } from 'frontile';

<template>
  <div class='flex flex-wrap items-center gap-3'>
    <Button @size='xs'>Button xs</Button>
    <Button @size='sm'>Button sm</Button>
    <Button>Button md</Button>
    <Button @size='lg'>Button lg</Button>
    <Button @size='xl'>Button xl</Button>
    <Button @size='2xl'>Button 2xl</Button>
  </div>
</template>

With Icons

import { Button } from 'frontile';
import { DownloadIcon, ShareIcon, CheckIcon } from 'site/components/icons';

<template>
  <div class='flex flex-wrap items-center gap-3'>
    <Button @size='xs' @color='primary'><DownloadIcon /> Download</Button>
    <Button @size='sm' @color='primary'><DownloadIcon /> Download</Button>
    <Button @color='primary'><DownloadIcon /> Download</Button>
    <Button @size='lg' @color='primary'><DownloadIcon /> Download</Button>
    <Button @size='xl' @color='primary'><DownloadIcon /> Download</Button>
  </div>
</template>

Icons can be placed before or after text. They inherit the button's text color via currentColor.

import { Button } from 'frontile';
import { DownloadIcon, ShareIcon, CheckIcon } from 'site/components/icons';

<template>
  <div class='flex gap-4'>
    <Button @color='primary'><DownloadIcon /> Download</Button>
    <Button @variant='outline' @color='neutral'>Share
      <ShareIcon /></Button>
    <Button @variant='soft' @color='success'><CheckIcon /> Confirm</Button>
  </div>
</template>

Icons passed as plain content keep working exactly as above. The icon named block is opt-in sugar: it buys you @iconPlacement and, when the button is loading, it is the slot the spinner takes over.

import { Button } from 'frontile';
import { ShareIcon } from 'site/components/icons';

<template>
  <Button>
    <:icon><ShareIcon /></:icon>
    <:default>Share</:default>
  </Button>
</template>

Label with a Unit

A label can carry a smaller trailing unit — a price suffix like /mo, a count, or an abbreviation. The label keeps the bold strong text role that the size variant already applies; the unit uses the regular-weight body role, two steps down the scale.

Wrap the pair so the unit sits tight against the label: the button's own gap spaces the icon slots, while the wrapper's narrower gap spaces label from unit.

import { Button } from 'frontile';
import { StarIcon } from 'site/components/icons';

<template>
  <div class='flex flex-wrap items-center gap-3'>
    <Button @color='primary'>
      <span class='inline-flex items-center gap-1'>
        Upgrade
        <span class='font-body text-body-xs'>/mo</span>
      </span>
    </Button>

    <Button @variant='outline' @color='neutral'>
      <StarIcon />
      <span class='inline-flex items-center gap-1'>
        Button
        <span class='font-body text-body-xs'>/mo</span>
      </span>
      <StarIcon />
    </Button>
  </div>
</template>

The unit token pairs with the label token the size variant sets, so it needs to change with @size:

@size label (automatic) unit wrapper gap
xs text-strong-sm text-body-3xs gap-0.5
sm text-strong-md text-body-2xs gap-0.5
md text-strong-lg text-body-xs gap-1
lg text-strong-xl text-body-sm gap-1
xl text-strong-2xl text-body-md gap-1
2xl text-strong-3xl text-body-lg gap-1.5
import { Button } from 'frontile';

<template>
  <div class='flex flex-wrap items-center gap-3'>
    <Button @size='sm' @color='primary'>
      <span class='inline-flex items-center gap-0.5'>
        $9
        <span class='font-body text-body-2xs'>/mo</span>
      </span>
    </Button>
    <Button @color='primary'>
      <span class='inline-flex items-center gap-1'>
        $19
        <span class='font-body text-body-xs'>/mo</span>
      </span>
    </Button>
    <Button @size='lg' @color='primary'>
      <span class='inline-flex items-center gap-1'>
        $29
        <span class='font-body text-body-sm'>/mo</span>
      </span>
    </Button>
    <Button @size='2xl' @color='primary'>
      <span class='inline-flex items-center gap-1.5'>
        $99
        <span class='font-body text-body-lg'>/mo</span>
      </span>
    </Button>
  </div>
</template>

Disabled

import { Button } from 'frontile';

<template>
  <div class='flex flex-wrap items-center gap-3'>
    <Button @color='neutral' disabled>Default</Button>
    <Button @color='primary' disabled>Primary</Button>
    <Button @color='secondary' disabled>Secondary</Button>
    <Button @color='tertiary' disabled>Tertiary</Button>
    <Button @color='success' disabled>Success</Button>
    <Button @color='warning' disabled>Warning</Button>
    <Button @color='danger' disabled>Danger</Button>
  </div>
</template>

Loading

@isLoading renders a spinner and disables the button.

import { Button } from 'frontile';

<template>
  <div class='flex flex-wrap items-center gap-3'>
    <Button @isLoading={{true}}>Save</Button>
    <Button @variant='outline' @isLoading={{true}}>Save</Button>
    <Button @color='danger' @isLoading={{true}}>Delete</Button>
  </div>
</template>

Use the loading block to swap the label while the action is in flight.

import { Button } from 'frontile';

<template>
  <Button @isLoading={{true}}>
    <:default>Save</:default>
    <:loading>Saving…</:loading>
  </Button>
</template>

The spinner takes the place of the icon block, so a button with an icon keeps its width while loading.

import { Button } from 'frontile';
import { ShareIcon } from 'site/components/icons';

<template>
  <div class='flex flex-wrap items-center gap-3'>
    <Button>
      <:icon><ShareIcon /></:icon>
      <:default>Share</:default>
    </Button>
    <Button @isLoading={{true}}>
      <:icon><ShareIcon /></:icon>
      <:default>Share</:default>
    </Button>
  </div>
</template>

@iconPlacement='end' moves both the icon and the spinner after the label.

import { Button } from 'frontile';
import { ShareIcon } from 'site/components/icons';

<template>
  <div class='flex flex-wrap items-center gap-3'>
    <Button @iconPlacement='end'>
      <:icon><ShareIcon /></:icon>
      <:default>Share</:default>
    </Button>
    <Button @iconPlacement='end' @isLoading={{true}}>
      <:icon><ShareIcon /></:icon>
      <:default>Share</:default>
    </Button>
  </div>
</template>

Renderless Button

Sometimes a button element is not ideal for a given case, but the same styles are still desired. Frontile provides the option to disable rendering the button element, but instead it yields back an object with the class names it would use.

import { Button } from 'frontile';

<template>
  <Button @isRenderless={{true}} as |btn|>
    <a href='#composition' class={{btn.classNames}}>Jump to composition</a>
  </Button>
</template>

Composition

You can compose variant with colors and more to create the button that best fits your needs.

import { Button } from 'frontile';

<template>
  <Button @variant='outline' @color='primary'>Button</Button>
  <Button @variant='plain' @color='warning'>Button</Button>
  <Button @size='xs' @color='danger'>Button</Button>
</template>

Customization

You can use TailwindCSS classes to customize even further.

import { Button } from 'frontile';

<template>
  <Button @variant='outline' @color='primary' @class='px-20 py-2 italic'>
    Button
  </Button>
</template>

Here is another example using TailwindCSS classes with the custom variant.

import { Button } from 'frontile';

<template>
  <Button
    @variant='custom'
    class='rounded-none border-teal-600 bg-teal-100 text-teal-900 hover:bg-teal-200 hover:text-teal-700 dark:border-teal-300 dark:bg-teal-950 dark:text-teal-100 dark:hover:bg-teal-900 dark:hover:text-teal-50 border-dashed'
  >
    Button
  </Button>
</template>

Note that here we used the HTML attribute class, instead of the argument @class. Using the class attribute will just append the class names passed in, while the argument @class will override and merge TailwindCSS class names.

Press Interactions

The Button component supports press interactions through the @onPress callback, which provides cross-platform support for mouse, touch, and keyboard events.

Button has been pressed 0 times

import { Button } from 'frontile';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';

export default class ButtonPressExample extends Component {
  @tracked pressCount = 0;

  handlePress = () => {
    this.pressCount++;
  };

  <template>
    <div class='flex items-center space-x-4'>
      <Button @onPress={{this.handlePress}}>
        Press me! ({{this.pressCount}})
      </Button>
      <p>Button has been pressed {{this.pressCount}} times</p>
    </div>
  </template>
}

Press State

Buttons automatically track their pressed state and add a data-pressed attribute when being pressed, which can be used for styling:

button[data-pressed='true'] {
  transform: scale(0.95);
  transition: transform 0.1s ease;
}

Accessibility

Button renders a native <button type="button">, so focus order, the disabled state, and Enter/Space activation come from the platform. What follows is what you still have to get right.

Key Behaviour
Tab Moves focus to the button. Disabled buttons are skipped.
Enter Activates the button, firing @onPress.
Space Activates the button, firing @onPress.

Use @onPress, not a click listener

The press modifier calls preventDefault() on Enter and Space so the two keys behave identically across elements. A side effect is that the browser never synthesises the click event it would normally follow with — so a {{on "click"}} handler on a default @type='button' fires for the mouse but not for the keyboard:

Activate each with the mouse, then again with Tab + Enter. Only the first counter moves the second time.

import { Button } from 'frontile';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';

export default class KeyboardParityExample extends Component {
  @tracked pressCount = 0;
  @tracked clickCount = 0;

  handlePress = () => {
    this.pressCount++;
  };

  handleClick = () => {
    this.clickCount++;
  };

  <template>
    <div class='flex flex-wrap items-center gap-4'>
      <Button @color='primary' @onPress={{this.handlePress}}>
        @onPress ({{this.pressCount}})
      </Button>
      <Button @variant='outline' {{on 'click' this.handleClick}}>
        click listener ({{this.clickCount}})
      </Button>
      <p class='text-neutral'>
        Activate each with the mouse, then again with
        <kbd>Tab</kbd>
        +
        <kbd>Enter</kbd>. Only the first counter moves the second time.
      </p>
    </div>
  </template>
}

@type='submit' and @type='reset' are exempt — the default is preserved there, so a button inside a form still submits or resets it from the keyboard.

Buttons with no text

An icon on its own leaves the button unnamed. Give it an aria-label — it passes through to the element via ...attributes:

import { Button } from 'frontile';
import { ShareIcon } from 'site/components/icons';

<template>
  <Button @color='primary' aria-label='Share this page'>
    <ShareIcon />
  </Button>
</template>

Disabling

There is no @isDisabled argument. Pass the plain HTML disabled attribute, as the Disabled demo above does; it removes the button from the tab order and is what assistive technology reports.

Loading

@isLoading sets the plain HTML disabled attribute, which has two consequences worth designing around.

Focus is lost. A button that disables itself mid-press drops focus to <body>, so keyboard and screen reader users lose their place and hear nothing. Put the outcome of the action in a live region, or leave the button enabled and guard against re-entry inside your handler.

The button widens by the spinner plus the gap, unless it has an icon block for the spinner to take over. Either use the icon block, or set a min-w-* class on buttons that toggle between states.

A plain-content icon is not replaced. The spinner only swaps in for an icon block (see With Icons); an icon passed as ordinary content — <Button @isLoading={{true}}><DownloadIcon /> Download</Button> — stays on screen alongside the spinner, rendering both. If you want the loading-swap behavior, move the icon into <:icon>.

Renderless buttons

@isRenderless hands back only class names, so every semantic the <button> provided becomes yours. An <a href> is already focusable and activates on Enter; anything else — a <div>, a <span> — needs role='button', tabindex='0', and its own key handling. Prefer a real <button> or <a> over recreating that.

@isRenderless yields only to the default block — <:icon> and <:loading> are ignored, because there is no element for the component to compose. The yielded hash still carries isLoading, so you can branch on it yourself.

API

Button

Element: HTMLButtonElement

Arguments

Name Type Default Description
appearance
Deprecated
enum -

Deprecated. Use `variant`. `default` is now `solid`, `outlined` is `outline`, and `minimal` is `plain`.

class string - Custom class name, it will override the default ones using Tailwind Merge library.
color enum - The color of the button
iconPlacement enum 'start' Which side the icon block — and the loading spinner that replaces it — sits on.
intent
Deprecated
enum -

Deprecated. Use `color`. `default` is now `neutral`.

isInGroup boolean - If button is part of a group. Most of the time, this is automatically set when using the ButtonGroup component.
isLoading boolean false Renders a spinner in place of the icon and disables the button.
isRenderless boolean - Disable rendering the button element. It yields an object with classNames instead.
onPress function - Callback for when the button is pressed.
size enum - The size of the button
type enum 'button' The HTML type of the button
variant enum 'solid' The button variant.

Blocks

Name Type Default Description
default * Array -
icon Array -
loading Array -
Released under MIT License - Created by Josemar Luedke