Button
A polymorphic button with six variants, eleven color schemes, icon slots and built-in loading states.
"use client";
import { Button, Icon, HStack } from "astralis-ui";
import { Sparkles } from "lucide-react";
export function ButtonDemo() {
return (
<HStack gap="3" wrap="wrap" justifyContent="center">
<Button leftIcon={<Icon as={Sparkles} size="xs" />}>Get started</Button>
<Button variant="subtle">Learn more</Button>
<Button variant="outline" colorScheme="gray">
Cancel
</Button>
</HStack>
);
}Import#
import { Button } from "astralis-ui";Usage#
The default button is solid in the brand color at size md. Every visual
decision — variant, hue, size, radius — is one prop away.
import { Button, HStack } from "astralis-ui";
export function ButtonVariants() {
return (
<HStack gap="3" wrap="wrap" justifyContent="center">
<Button variant="solid">Solid</Button>
<Button variant="subtle">Subtle</Button>
<Button variant="surface">Surface</Button>
<Button variant="outline">Outline</Button>
<Button variant="text">Text</Button>
<Button variant="link">Link</Button>
</HStack>
);
}- solid — the signature fill. Use it for the single primary action on a screen.
- subtle — a tinted fill with no border, for secondary actions.
- surface — the bordered sibling of
subtle. - outline — border only; fills with a faint tint on hover.
- text — no chrome until hover (a ghost button).
- link — an inline-link affordance with an underline on hover.
Color schemes#
Every variant paints through the accent channel, so a single colorScheme
prop recolors any button — including runtime brand colors.
Use gray for neutral actions and red for destructive ones. The full
matrix below shows all six variants across all eleven schemes.
import { Button, Text, VStack, HStack } from "astralis-ui";
const variants = ["solid", "subtle", "surface", "outline", "text", "link"] as const;
const schemes = [
"brand", "gray", "red", "orange", "yellow", "green",
"teal", "blue", "cyan", "purple", "pink",
] as const;
export function ButtonColorSchemes() {
return (
<VStack gap="6" alignItems="stretch">
{variants.map((variant) => (
<VStack key={variant} gap="2" alignItems="stretch">
<Text as="span" size="xs" weight="medium" color="muted">
{variant}
</Text>
<HStack gap="2" wrap="wrap">
{schemes.map((scheme) => (
<Button key={scheme} size="xs" variant={variant} colorScheme={scheme}>
{scheme}
</Button>
))}
</HStack>
</VStack>
))}
</VStack>
);
}Sizes#
Five sizes, from compact toolbars to hero calls-to-action. Height, padding, font size and icon gap scale together.
import { Button, HStack } from "astralis-ui";
export function ButtonSizes() {
return (
<HStack gap="3" wrap="wrap" justifyContent="center">
<Button size="xs">Extra small</Button>
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
<Button size="xl">Extra large</Button>
</HStack>
);
}Rounded#
The corner radius is independent of size — go from sharp to pill with the
rounded prop.
import { Button, HStack } from "astralis-ui";
export function ButtonRounded() {
return (
<HStack gap="3" wrap="wrap" justifyContent="center">
<Button variant="surface" rounded="none">None</Button>
<Button variant="surface" rounded="sm">Small</Button>
<Button variant="surface" rounded="lg">Large</Button>
<Button variant="surface" rounded="2xl">2XL</Button>
<Button variant="surface" rounded="full">Full</Button>
</HStack>
);
}Icons#
Pass any element to leftIcon or rightIcon. Omit the label and the button
becomes a perfect square — icon-only buttons must carry an aria-label
(the library warns in development if one is missing).
"use client";
import { Button, Icon, HStack } from "astralis-ui";
import { Search, ChevronDown, Settings, Trash2 } from "lucide-react";
export function ButtonIcons() {
return (
<HStack gap="3" wrap="wrap" justifyContent="center">
<Button leftIcon={<Icon as={Search} size="xs" />}>Search</Button>
<Button variant="outline" rightIcon={<Icon as={ChevronDown} size="xs" />}>
Open menu
</Button>
{/* Icon-only buttons need an aria-label for assistive tech. */}
<Button variant="subtle" leftIcon={<Icon as={Settings} size="xs" />} aria-label="Settings" />
<Button
variant="subtle"
colorScheme="red"
leftIcon={<Icon as={Trash2} size="xs" />}
aria-label="Delete"
/>
</HStack>
);
}Loading state#
loading swaps in a spinner and blocks interaction without a layout shift.
loadingText replaces the label while pending, and loaderPlacement moves
the spinner to either side. Bring your own spinner via loader.
"use client";
import { useState } from "react";
import { Button, HStack } from "astralis-ui";
export function ButtonLoading() {
const [saving, setSaving] = useState(false);
const save = () => {
setSaving(true);
setTimeout(() => setSaving(false), 2000);
};
return (
<HStack gap="3" wrap="wrap" justifyContent="center">
<Button loading>Loading</Button>
<Button variant="outline" loading loadingText="Submitting…" />
<Button variant="subtle" loading loaderPlacement="end" loadingText="Uploading" />
<Button onClick={save} loading={saving} loadingText="Saving…">
Click to save
</Button>
</HStack>
);
}Full width#
fullWidth stretches the button to its container — typical for checkout
flows and dialog footers.
"use client";
import { Button, Icon, VStack } from "astralis-ui";
import { CreditCard } from "lucide-react";
export function ButtonFullWidth() {
return (
<VStack gap="3" w="full" maxW="sm">
<Button fullWidth leftIcon={<Icon as={CreditCard} size="xs" />}>
Complete checkout
</Button>
<Button fullWidth variant="outline" colorScheme="gray">
Continue shopping
</Button>
</VStack>
);
}Rendering as a link#
Button is polymorphic: pass as to change the underlying element and the
accepted props follow along type-safely. Disabled non-button elements get
aria-disabled and leave the tab order instead of the disabled attribute.
"use client";
import { Button, Icon, HStack } from "astralis-ui";
import { ExternalLink } from "lucide-react";
export function ButtonAsLink() {
return (
<HStack gap="3" wrap="wrap" justifyContent="center">
{/* Renders a real <a>; href/target come from the anchor's prop types. */}
<Button
as="a"
href="https://github.com"
target="_blank"
variant="surface"
rightIcon={<Icon as={ExternalLink} size="xs" />}
>
View on GitHub
</Button>
<Button as="a" href="#usage" variant="link">
Jump to usage
</Button>
</HStack>
);
}Buttons compose with a surrounding Button Group,
which supplies shared variant, colorScheme, size and disabled defaults —
an explicit prop on a child always wins.
Props#
Button also accepts every prop of the element it renders as (button by default).
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "solid" | "subtle" | "surface" | "outline" | "text" | "link" | "solid" | Visual style. surface is the bordered sibling of subtle; text is the ghost style. |
colorScheme | "brand" | "gray" | "red" | "orange" | "yellow" | "green" | "teal" | "blue" | "cyan" | "purple" | "pink" | "brand" | Hue the variant paints with. Use gray for a neutral button. |
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Height, padding, font size and icon gap scale together. |
rounded | "none" | "sm" | "md" | "lg" | "xl" | "2xl" | "full" | "lg" | Corner radius. |
fullWidth | boolean | false | Stretches the button to fill its container. |
disabled | boolean | false | Disables the button. On non-button elements this maps to aria-disabled and removes it from the tab order. |
loading | boolean | false | Shows a spinner and disables interaction. |
loadingText | ReactNode | — | Replaces the label while loading (e.g. “Saving…”); the spinner still shows. |
loaderPlacement | "start" | "end" | "start" | Which side the spinner renders on. |
loader | ReactNode | — | Custom spinner element, replacing the built-in one. |
leftIcon | ReactNode | — | Icon rendered before the label. With no label the button becomes icon-only. |
rightIcon | ReactNode | — | Icon rendered after the label. |
as | ElementType | "button" | Element or component to render. Forwarded HTML props follow the chosen element automatically. |
Accessibility#
- Renders a native
<button>by default, so keyboard and screen-reader semantics come for free. - Icon-only buttons need an
aria-label; a development-mode warning fires when it's missing. disabledandloadingon non-button elements map toaria-disabledandtabIndex={-1}rather than the invaliddisabledattribute.- Focus is always visible: a two-pixel outline in the active color scheme's ring token, offset from the button edge.