Skip to content

Button

Displays a Discord-styled button with brand, secondary, success, danger and link variants.

The button carries six intents and four sizes. Which one you reach for is a statement about what the action does, not about how it should look — danger is destructive, success is confirming, link is navigation that happens to sit in a row of buttons.

Root class newt-btn
import { Button } from "@/registry/bases/newt/ui/button"

/* Inline so the demo never reaches out to the network for a placeholder. */
const EMOJI =
  "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='7' fill='%23f0b232'/%3E%3C/svg%3E"

export default function ButtonDemo() {
  return (
    <div className="flex flex-wrap items-center gap-3">
      <Button variant="primary">Primary</Button>
      <Button variant="secondary">Secondary</Button>
      <Button variant="success">Success</Button>
      <Button variant="danger">Danger</Button>
      <Button variant="secondary" emoji={EMOJI}>
        With emoji
      </Button>
      <Button variant="link">Link button</Button>
    </div>
  )
}
<script setup lang="ts">
import { Button } from "~~/registry/bases/newt/ui/button"

/* Inline so the demo never reaches out to the network for a placeholder. */
const EMOJI =
  "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='7' fill='%23f0b232'/%3E%3C/svg%3E"
</script>

<template>
  <div class="flex flex-wrap items-center gap-3">
    <Button variant="primary">Primary</Button>
    <Button variant="secondary">Secondary</Button>
    <Button variant="success">Success</Button>
    <Button variant="danger">Danger</Button>
    <Button variant="secondary" :emoji="EMOJI">With emoji</Button>
    <Button variant="link">Link button</Button>
  </div>
</template>

Installation

pnpm dlx newtui@latest add button
npx newtui@latest add button
yarn dlx newtui@latest add button
bunx newtui@latest add button

Install the dependencies

pnpm dlx --yes class-variance-authority
npx --yes class-variance-authority
yarn dlx --yes class-variance-authority
bunx --yes class-variance-authority

Copy the source into your project

import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react"

import { cn } from "@/lib/utils"

const buttonVariants = cva(
  "inline-flex cursor-pointer select-none items-center justify-center gap-2 whitespace-nowrap border-0 font-sans font-medium leading-4 transition-[background-color,color,box-shadow] duration-fast ease-newt active:translate-y-[0.5px] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-newt-text-link disabled:cursor-not-allowed disabled:opacity-50",
  {
    variants: {
      variant: {
        primary:
          "bg-newt-brand text-white hover:bg-newt-brand-hover active:bg-newt-brand-active",
        secondary:
          "bg-newt-bg-elevated text-newt-text-primary hover:bg-newt-bg-hover",
        danger: "bg-newt-danger text-white hover:bg-newt-danger-hover",
        success: "bg-newt-online text-white hover:brightness-90",
        link: "bg-transparent text-newt-text-link hover:underline",
      },
      size: {
        default: "rounded-sm px-4 py-2.5 text-sm",
        sm: "rounded-sm px-3 py-1.5 text-[13px]",
        lg: "rounded-sm px-6 py-3 text-base",
        icon: "rounded-full p-2 text-sm",
      },
    },
    compoundVariants: [
      { variant: "link", size: "default", class: "px-0 py-1" },
      { variant: "link", size: "sm", class: "px-0 py-1" },
      { variant: "link", size: "lg", class: "px-0 py-1" },
    ],
    defaultVariants: {
      variant: "primary",
      size: "default",
    },
  }
)

/* The "opens elsewhere" glyph Discord puts on link buttons. */
const LaunchIcon = () => (
  <svg
    viewBox="0 0 24 24"
    aria-hidden="true"
    focusable="false"
    className="h-4 w-4 shrink-0"
  >
    <path
      fill="currentColor"
      d="M10 5a1 1 0 0 0 0 2h6.59L4.3 19.3a1 1 0 1 0 1.4 1.4L18 8.42V15a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1h-9Z"
    />
  </svg>
)

export interface ButtonProps
  extends
    React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  /** Emoji image URL, rendered before the label. */
  emoji?: string
  /** Accessible name for `emoji`; empty (the default) hides it from readers. */
  emojiAlt?: string
  /** Link-out glyph after the label. Defaults on for `variant="link"`. */
  launchIcon?: boolean
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  (
    {
      className,
      variant,
      size,
      type = "button",
      emoji,
      emojiAlt = "",
      launchIcon,
      children,
      ...props
    },
    ref
  ) => {
    const showLaunch = launchIcon ?? variant === "link"
    return (
      <button
        ref={ref}
        type={type}
        className={cn(buttonVariants({ variant, size }), className)}
        {...props}
      >
        {emoji ? (
          <img
            src={emoji}
            alt={emojiAlt}
            aria-hidden={emojiAlt === "" ? true : undefined}
            draggable={false}
            className="h-[1.375em] w-[1.375em] shrink-0 object-contain align-bottom"
          />
        ) : null}
        <span className="truncate">{children}</span>
        {showLaunch ? <LaunchIcon /> : null}
      </button>
    )
  }
)
Button.displayName = "Button"

export { Button, buttonVariants }
<script setup lang="ts">
import type { ButtonHTMLAttributes, HTMLAttributes } from "vue"
import { computed } from "vue"

import { cn } from "@/lib/utils"

import { type ButtonVariants, buttonVariants } from "."

const props = withDefaults(
  defineProps<{
    variant?: ButtonVariants["variant"]
    size?: ButtonVariants["size"]
    type?: ButtonHTMLAttributes["type"]
    disabled?: boolean
    class?: HTMLAttributes["class"]
    /** Emoji image URL, rendered before the label. */
    emoji?: string
    /** Accessible name for `emoji`; empty (the default) hides it from readers. */
    emojiAlt?: string
    /** Link-out glyph after the label. Defaults on for `variant="link"`. */
    launchIcon?: boolean
  }>(),
  /*
   * `launchIcon` needs `undefined` to mean "let the variant decide" — but Vue
   * casts an absent prop typed `boolean` to `false` unless it has a `default`
   * key at all, present or not. Listing it here with the value `undefined` is
   * what keeps that cast from firing, so the `??` below ever sees anything
   * but `false`.
   */
  { type: "button", emojiAlt: "", launchIcon: undefined }
)

const showLaunch = computed(() => props.launchIcon ?? props.variant === "link")
</script>

<template>
  <button
    :type="props.type"
    :disabled="props.disabled"
    :class="
      cn(
        buttonVariants({ variant: props.variant, size: props.size }),
        props.class
      )
    "
  >
    <img
      v-if="props.emoji"
      :src="props.emoji"
      :alt="props.emojiAlt"
      :aria-hidden="props.emojiAlt === '' ? true : undefined"
      :draggable="false"
      class="h-[1.375em] w-[1.375em] shrink-0 object-contain align-bottom"
    />
    <span class="truncate"><slot /></span>
    <!-- The "opens elsewhere" glyph Discord puts on link buttons. -->
    <svg
      v-if="showLaunch"
      viewBox="0 0 24 24"
      aria-hidden="true"
      focusable="false"
      class="h-4 w-4 shrink-0"
    >
      <path
        fill="currentColor"
        d="M10 5a1 1 0 0 0 0 2h6.59L4.3 19.3a1 1 0 1 0 1.4 1.4L18 8.42V15a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1h-9Z"
      />
    </svg>
  </button>
</template>
import { cva, type VariantProps } from "class-variance-authority"

export { default as Button } from "./Button.vue"

export const buttonVariants = cva(
  "inline-flex cursor-pointer select-none items-center justify-center gap-2 whitespace-nowrap border-0 font-sans font-medium leading-4 transition-[background-color,color,box-shadow] duration-fast ease-newt active:translate-y-[0.5px] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-newt-text-link disabled:cursor-not-allowed disabled:opacity-50",
  {
    variants: {
      variant: {
        primary:
          "bg-newt-brand text-white hover:bg-newt-brand-hover active:bg-newt-brand-active",
        secondary:
          "bg-newt-bg-elevated text-newt-text-primary hover:bg-newt-bg-hover",
        danger: "bg-newt-danger text-white hover:bg-newt-danger-hover",
        success: "bg-newt-online text-white hover:brightness-90",
        link: "bg-transparent text-newt-text-link hover:underline",
      },
      size: {
        default: "rounded-sm px-4 py-2.5 text-sm",
        sm: "rounded-sm px-3 py-1.5 text-[13px]",
        lg: "rounded-sm px-6 py-3 text-base",
        icon: "rounded-full p-2 text-sm",
      },
    },
    compoundVariants: [
      { variant: "link", size: "default", class: "px-0 py-1" },
      { variant: "link", size: "sm", class: "px-0 py-1" },
      { variant: "link", size: "lg", class: "px-0 py-1" },
    ],
    defaultVariants: {
      variant: "primary",
      size: "default",
    },
  }
)

export type ButtonVariants = VariantProps<typeof buttonVariants>

Update the import paths

The files import @/lib/utils and sibling components through the aliases in your components.json. Point them at wherever those live in your project.

Usage

import { Button } from "@/components/ui/button"

export function Example() {
  return <Button />
}
<script setup lang="ts">
import { Button } from "@/components/ui/button"
</script>

<template>
  <Button />
</template>

API reference

Button

PropType
variant?"primary" | "secondary" | "success" | "danger" | "link"

What the action does. Never picked for its colour.

Type"primary" | "secondary" | "success" | "danger" | "link"
Default"primary"
size?"default" | "sm" | "lg" | "icon"

Padding and text size. `icon` makes it a circle.

Type"default" | "sm" | "lg" | "icon"
Default"default"
emoji?string

Image URL rendered before the label.

Typestring
emojiAlt?string

Accessible name for the emoji. Empty hides it from readers, which is right when the label already says it.

Typestring
Default""
launchIcon?boolean

Link-out glyph after the label.

Typeboolean
Defaultvariant === "link"

Every part also accepts the native attributes of the element it renders, and a className that is merged rather than replaced.

Accessibility

It renders a real <button>, so Enter and Space activate it and disabled is honoured by the browser rather than emulated. An icon-only button (size="icon" with no text) has no visible text: give it an aria-label. The focus ring is a 2px --newt-text-link outline at 2px offset, and it is not removed on :focus-visible.

Tokens

Button reads its colours, radii and motion from the --newt-* tokens installed with the library. Override a token and every component that reads it follows, including this one — see the token layer. In the plain HTML and CSS distribution the same component is newt-btn.