Variants
Nine emphasis levels. Use one neutral-white primary per view, reach for accent when a second action needs weight, and let secondary, outline, and ghost carry the rest; danger is reserved for destructive actions, with ghost-danger its low-emphasis twin — a transparent ghost weight painted in the danger tone for an inline destructive action like "Sign out everywhere", where the wash deepens from hover to active and the focus ring stays danger-coloured so the intent survives interaction; and link sits inline with text — with link-foreground its quieter twin, painting the foreground colour for inline links on dark or accent surfaces where the accent blue would disappear.
import { Button } from "@stridge/noctis/button";
export default function ButtonVariants() {
return (
<div className="flex flex-col items-center gap-4">
<div className="flex flex-wrap items-center justify-center gap-3">
<Button variant="primary">Primary</Button>
<Button variant="accent">Accent</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="outline">Outline</Button>
<Button variant="danger">Danger</Button>
</div>
<div className="flex flex-wrap items-center justify-center gap-3">
<Button variant="ghost">Ghost</Button>
<Button variant="ghost-danger">Ghost danger</Button>
<Button variant="link">Link</Button>
<Button variant="link-foreground">Link foreground</Button>
</div>
</div>
);
}
Sizes
Four heights — extra-small through large — sharing one type and spacing rhythm.
import { Button } from "@stridge/noctis/button";
export default function ButtonSizes() {
return (
<div className="flex flex-wrap items-center gap-3">
<Button size="xs">Extra small</Button>
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
</div>
);
}
With icons
Pair a leading or trailing glyph with the label. Icons are decorative; the label names the action.
"use client";
import { Button } from "@stridge/noctis/button";
import { ArrowRight, Download } from "lucide-react";
export default function ButtonWithIcons() {
return (
<div className="flex flex-wrap items-center gap-3">
<Button variant="secondary" startIcon={Download}>
Download
</Button>
<Button variant="secondary" endIcon={ArrowRight}>
Continue
</Button>
</div>
);
}
Icon only
Square the button for a lone glyph. Always pass an aria-label — without visible text, assistive technology needs it to announce the action.
"use client";
import { Button } from "@stridge/noctis/button";
import { Plus, Trash2 } from "lucide-react";
export default function ButtonIconOnly() {
return (
<div className="flex flex-wrap items-center gap-3">
<Button iconOnly aria-label="Add" startIcon={Plus} />
<Button variant="secondary" iconOnly aria-label="Add" startIcon={Plus} />
<Button variant="ghost" iconOnly aria-label="Delete" startIcon={Trash2} />
</div>
);
}
Loading
A loading button shows a centered spinner, blocks interaction, and keeps its size so the layout never jumps.
import { Button } from "@stridge/noctis/button";
export default function ButtonLoading() {
return (
<div className="flex flex-wrap items-center gap-3">
<Button loading>Saving</Button>
<Button variant="secondary" loading>
Loading
</Button>
</div>
);
}
Disabled
A disabled button dims to the disabled opacity and ignores interaction.
import { Button } from "@stridge/noctis/button";
export default function ButtonDisabled() {
return (
<div className="flex flex-wrap items-center gap-3">
<Button disabled>Primary</Button>
<Button variant="secondary" disabled>
Secondary
</Button>
</div>
);
}
Full width
Stretch the button to fill its container — common in narrow forms and on mobile.
import { Button } from "@stridge/noctis/button";
export default function ButtonFullWidth() {
return (
<div className="w-full max-w-sm">
<Button fullWidth>Continue</Button>
</div>
);
}
Link as button
For navigation, style a link with buttonVariants so it stays a real anchor while wearing the button's look; use the Button itself for in-place actions.
import { buttonProps } from "@stridge/noctis/props";
export default function ButtonAsLink() {
return (
<div className="flex flex-wrap items-center gap-3">
<a href="#button" {...buttonProps({ variant: "primary" })}>
Go to dashboard
</a>
<a href="https://stridge.com" {...buttonProps({ variant: "secondary" })}>
Stridge.com
</a>
</div>
);
}
Theme scope
ThemeScope re-tunes a whole region's shape — corner radius, density, and type scale — through the cascade, with no per-button prop and without touching the app-wide theme. Tweak the knobs below and watch every button inside the scope retune together; surfaces stay capped, so a pill radius rounds the controls without ballooning cards. See Customization for nesting, reset, and the portal caveat.
"use client";
import { DENSITY_PRESETS, FONT_SCALE_PRESETS, RADIUS_PRESETS, ThemeScope } from "@stridge/noctis";
import { Button } from "@stridge/noctis/button";
import { Select } from "@stridge/noctis/select";
import { ArrowRight } from "lucide-react";
import { useState } from "react";
/** Title-case a preset key for its select label. */
const label = (key: string) => key.charAt(0).toUpperCase() + key.slice(1);
/** One labelled preset picker — its options are a shipped `ThemeScope` preset map, so the playground's
* vocab is exactly what the primitive accepts. */
function Knob({
name,
presets,
value,
onChange,
}: {
name: string;
presets: Record<string, number>;
value: string;
onChange: (value: string) => void;
}) {
const keys = Object.keys(presets);
const items = Object.fromEntries(keys.map((key) => [key, label(key)]));
return (
<label className="flex flex-col gap-1.5">
<span className="text-mini font-medium text-subtle">{name}</span>
<Select.Root items={items} value={value} onValueChange={(next) => onChange(String(next))}>
<Select.Trigger aria-label={name} className="w-40">
<Select.Value />
<Select.Icon />
</Select.Trigger>
<Select.Popup>
{keys.map((key) => (
<Select.Item key={key} value={key}>
{label(key)}
</Select.Item>
))}
</Select.Popup>
</Select.Root>
</label>
);
}
/**
* A live `ThemeScope` playground: the three seed knobs (radius, density, type scale) drive a scope
* wrapping a button cluster, so every control inside re-tunes together — corners, spacing, and text
* size — with no per-button prop and without touching the app-wide theme.
*/
export default function ButtonThemeScope() {
const [radius, setRadius] = useState<keyof typeof RADIUS_PRESETS>("pill");
const [density, setDensity] = useState<keyof typeof DENSITY_PRESETS>("default");
const [fontScale, setFontScale] = useState<keyof typeof FONT_SCALE_PRESETS>("default");
return (
<div className="flex flex-col gap-5">
<div className="flex flex-wrap gap-3">
<Knob name="Radius" presets={RADIUS_PRESETS} value={radius} onChange={(v) => setRadius(v as typeof radius)} />
<Knob
name="Density"
presets={DENSITY_PRESETS}
value={density}
onChange={(v) => setDensity(v as typeof density)}
/>
<Knob
name="Font scale"
presets={FONT_SCALE_PRESETS}
value={fontScale}
onChange={(v) => setFontScale(v as typeof fontScale)}
/>
</div>
<div className="rounded-md border border-dashed border-border p-4">
<ThemeScope radius={radius} density={density} fontScale={fontScale}>
<div className="flex flex-wrap items-center gap-3">
<Button variant="primary" endIcon={ArrowRight}>
Get started
</Button>
<Button variant="secondary">Learn more</Button>
<Button variant="ghost">Cancel</Button>
</div>
</ThemeScope>
</div>
</div>
);
}
On surfaces
The same control re-tuned across the elevation scopes — the root canvas, an elevated panel, a menu, and a sunken well. It stays legible on every layer.
Design tokens
Generated from the component's declaration — the same graph that mints the CSS, so a variable name or its resolution default can't drift. The minted tokens are the public override seam: set one on any ancestor and every button in that region retunes — e.g. .marketing { --noctis-button-border-radius: 9999px; } pills every button beneath it. Knobs that aren't minted are reached through the part's data-slot. See Customization for the full override ladder and Tokens for the whole graph.
API reference
Generated from the component's types — every prop, type, default, and description comes straight from the source. Button extends the Base UI Button props, so nativeButton and the native button attributes pass straight through; expand a row for the full type and description.