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.
newt-btnimport { 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 buttonnpx newtui@latest add buttonyarn dlx newtui@latest add buttonbunx newtui@latest add buttonInstall the dependencies
pnpm dlx --yes class-variance-authoritynpx --yes class-variance-authorityyarn dlx --yes class-variance-authoritybunx --yes class-variance-authorityCopy 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
variant?"primary" | "secondary" | "success" | "danger" | "link"
What the action does. Never picked for its colour.
"primary" | "secondary" | "success" | "danger" | "link""primary"size?"default" | "sm" | "lg" | "icon"
Padding and text size. `icon` makes it a circle.
"default" | "sm" | "lg" | "icon""default"emoji?string
Image URL rendered before the label.
stringemojiAlt?string
Accessible name for the emoji. Empty hides it from readers, which is right when the label already says it.
string""launchIcon?boolean
Link-out glyph after the label.
booleanvariant === "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.