Skip to main content

A clickable button component with multiple variants and states.

Import

import { Button } from "heroui-solid";

Usage

import { Button } from "heroui-solid"

export function Basic() {
  return <Button onClick={() => console.log("Button pressed")}>Click me</Button>
}

Variants

import { Button } from "heroui-solid"

export function Variants() {
  return (
    <div class="flex flex-wrap gap-3">
      <Button>Primary</Button>
      <Button variant="secondary">Secondary</Button>
      <Button variant="tertiary">Tertiary</Button>
      <Button variant="outline">Outline</Button>
      <Button variant="ghost">Ghost</Button>
      <Button variant="danger">Danger</Button>
      <Button variant="danger-soft">Danger Soft</Button>
    </div>
  )
}

With Icons

import { Envelope, Globe, Plus, TrashBin } from "gravity-icons-solid"
import { Button } from "heroui-solid"

export function WithIcons() {
  return (
    <div class="flex flex-wrap gap-3">
      <Button>
        <Globe />
        Search
      </Button>
      <Button variant="secondary">
        <Plus />
        Add Member
      </Button>
      <Button variant="tertiary">
        <Envelope />
        Email
      </Button>
      <Button variant="danger">
        <TrashBin />
        Delete
      </Button>
    </div>
  )
}

Icon Only

import { Ellipsis, Gear, TrashBin } from "gravity-icons-solid"
import { Button } from "heroui-solid"

export function IconOnly() {
  return (
    <div class="flex gap-3">
      <Button isIconOnly variant="tertiary">
        <Ellipsis />
      </Button>
      <Button isIconOnly variant="secondary">
        <Gear />
      </Button>
      <Button isIconOnly variant="danger">
        <TrashBin />
      </Button>
    </div>
  )
}

Loading

import { Button, Spinner } from "heroui-solid"

export function Loading() {
  return (
    <Button isPending>
      {(state) => (
        <>
          {state.isPending && <Spinner color="current" size="sm" />}
          Uploading...
        </>
      )}
    </Button>
  )
}

Loading State

import { Paperclip } from "gravity-icons-solid"
import { Button, Spinner } from "heroui-solid"
import { createSignal } from "solid-js"

export function LoadingState() {
  const [isLoading, setLoading] = createSignal(false)

  const handleClick = () => {
    setLoading(true)
    setTimeout(() => setLoading(false), 2000)
  }

  return (
    <Button isPending={isLoading()} onClick={handleClick}>
      {(state) => (
        <>
          {state.isPending ? (
            <Spinner color="current" size="sm" />
          ) : (
            <Paperclip />
          )}
          {state.isPending ? "Uploading..." : "Upload File"}
        </>
      )}
    </Button>
  )
}

Sizes

import { Button } from "heroui-solid"

export function Sizes() {
  return (
    <div class="flex items-center gap-3">
      <Button size="sm">Small</Button>
      <Button size="md">Medium</Button>
      <Button size="lg">Large</Button>
    </div>
  )
}

Full Width

import { Plus } from "gravity-icons-solid"
import { Button } from "heroui-solid"

export function FullWidth() {
  return (
    <div class="w-[400px] space-y-3">
      <Button fullWidth>Primary Button</Button>
      <Button fullWidth>
        <Plus />
        With Icon
      </Button>
    </div>
  )
}

Disabled State

import { Button } from "heroui-solid"

export function Disabled() {
  return (
    <div class="flex flex-wrap gap-3">
      <Button isDisabled>Primary</Button>
      <Button isDisabled variant="secondary">
        Secondary
      </Button>
      <Button isDisabled variant="tertiary">
        Tertiary
      </Button>
      <Button isDisabled variant="outline">
        Outline
      </Button>
      <Button isDisabled variant="ghost">
        Ghost
      </Button>
      <Button isDisabled variant="danger">
        Danger
      </Button>
    </div>
  )
}

Social Buttons

import { Button } from "heroui-solid"
import GoogleIcon from "~icons/devicon/google"
import AppleIcon from "~icons/ion/logo-apple"
import GitHubIcon from "~icons/mdi/github"

export function Social() {
  return (
    <div class="flex w-full max-w-xs flex-col gap-3">
      <Button class="w-full" variant="tertiary">
        <GoogleIcon />
        Sign in with Google
      </Button>
      <Button class="w-full" variant="tertiary">
        <GitHubIcon />
        Sign in with GitHub
      </Button>
      <Button class="w-full" variant="tertiary">
        <AppleIcon />
        Sign in with Apple
      </Button>
    </div>
  )
}

Custom Element

HeroUI React overrides the rendered element with a render prop. This port uses Kobalte's polymorphic as prop instead — for example, an anchor that looks like a button:

import { Button } from "heroui-solid"

export function CustomElement() {
  return (
    <Button
      as="a"
      href="https://heroui.com"
      rel="noopener noreferrer"
      target="_blank"
      variant="outline"
    >
      Visit HeroUI
    </Button>
  )
}
  • Popover: Displays content in context with a trigger (not ported yet)
  • Tooltip: Contextual information on hover or focus (not ported yet)
  • Form: Form validation and submission handling (not ported yet)

Styling

Passing Tailwind CSS classes

import { Button } from "heroui-solid";

function CustomButton() {
  return (
    <Button class="bg-purple-500 text-white hover:bg-purple-600">
      Purple Button
    </Button>
  );
}

Customizing the component classes

To customize the Button component classes, you can use the @layer components directive. Learn more.

@layer components {
  .button {
    @apply bg-purple-500 text-white hover:bg-purple-600;
  }

  .button--icon-only {
    @apply rounded-lg bg-blue-500;
  }
}

HeroUI follows the BEM methodology to ensure component variants and states are reusable and easy to customize.

Adding custom variants

You can extend HeroUI components by wrapping them and adding your own custom variants.

import type { ButtonProps } from "heroui-solid"
import { Button, buttonVariants } from "heroui-solid"
import { splitProps } from "solid-js"
import type { VariantProps } from "tailwind-variants"
import { tv } from "tailwind-variants"

const myButtonVariants = tv({
  base: "text-md font-semibold shadow-md text-shadow-lg data-[pending=true]:opacity-40",
  defaultVariants: {
    radius: "full",
    variant: "primary"
  },
  extend: buttonVariants,
  variants: {
    radius: {
      full: "rounded-full",
      lg: "rounded-lg",
      md: "rounded-md",
      sm: "rounded-sm"
    },
    size: {
      lg: "h-12 px-8",
      md: "h-11 px-6",
      sm: "h-10 px-4",
      xl: "h-13 px-10"
    },
    variant: {
      primary:
        "text-white dark:bg-white/10 dark:text-white dark:hover:bg-white/15"
    }
  }
})

type MyButtonVariants = VariantProps<typeof myButtonVariants>
export type MyButtonProps = Omit<ButtonProps, "class"> &
  MyButtonVariants & { class?: string }

function CustomButton(props: MyButtonProps) {
  const [local, rest] = splitProps(props, ["class", "radius", "variant"])
  return (
    <Button
      class={myButtonVariants({
        class: local.class,
        radius: local.radius,
        variant: local.variant
      })}
      {...rest}
    />
  )
}

export function CustomVariants() {
  return <CustomButton>Custom Button</CustomButton>
}

Adding Ripple Effect

HeroUI React adds a ripple through composition — nesting a React-only <Ripple /> child (e.g. m3-ripple). This Solid port ships the effect as a CSS flag instead: the --button-ripple custom property, off by default. Add the ripple class to enable it on a single button:

import { Button } from "heroui-solid"

export function Ripple() {
  return (
    <Button class="ripple" variant="secondary">
      Click me
    </Button>
  )
}

Because the property inherits, you can enable ripple for every button by setting it once at the root, then opt individual buttons back out with no-ripple:

:root {
  --button-ripple: 1;
}

The ripple expands from the press point and follows Material 3's touch heuristics: mouse and pen ripple immediately on press, keyboard activation ripples from the center, and a touch only ripples once it's clearly a tap or hold — a touch that turns into a scroll never fires it. It uses currentColor and honors prefers-reduced-motion; tune it with --button-ripple-color and --button-ripple-opacity.

CSS Classes

The Button component uses these CSS classes (View source styles):

Base & Size Classes

  • .button - Base button styles
  • .button--sm - Small size variant
  • .button--md - Medium size variant
  • .button--lg - Large size variant

Variant Classes

  • .button--primary
  • .button--secondary
  • .button--tertiary
  • .button--outline
  • .button--ghost
  • .button--danger
  • .button--danger-soft

Modifier Classes

  • .button--icon-only
  • .button--icon-only.button--sm
  • .button--icon-only.button--lg

Interactive States

The button supports CSS pseudo-classes and data attributes:

  • Hover: :hover
  • Active/Pressed: :active (includes scale transform)
  • Focus: :focus-visible (shows focus ring)
  • Disabled: :disabled or [aria-disabled="true"] (reduced opacity, no pointer events)
  • Pending: [data-pending] (no pointer events during loading)

API Reference

Button Props

PropTypeDefaultDescription
variant"primary" | "secondary" | "tertiary" | "outline" | "ghost" | "danger" | "danger-soft""primary"Visual style variant
size"sm" | "md" | "lg""md"Size of the button
fullWidthbooleanfalseWhether the button should take full width of its container
isDisabledbooleanfalseWhether the button is disabled
isPendingbooleanfalseWhether the button is in a loading state
isIconOnlybooleanfalseWhether the button contains only an icon
onClickJSX.EventHandlerUnion<HTMLElement, MouseEvent>-Handler called when the button is clicked
childrenJSX.Element | (state: { isPending: boolean }) => JSX.Element-Button content or render prop
asValidComponent"button"Overrides the default DOM element (Kobalte polymorphic as)

Differences from HeroUI React

  • Use onClick (Solid's native event) instead of React Aria's onPress.
  • Polymorphism uses Kobalte's as prop instead of React's render prop override.
  • The render-prop state exposes isPending only (no isPressed/isHovered/isFocused) — read fields off state inside JSX rather than destructuring, so updates stay reactive.
  • Hover/press styling relies on HeroUI's native :hover / :active CSS fallbacks rather than React Aria's data-hovered / data-pressed attributes — visuals are identical.

Last updated: 7/19/26, 3:27 AM

HeroUI SolidUnofficial SolidJS port of HeroUI v3, built on Kobalte and @heroui/styles