Neoconstruct button: hard offset shadow that presses in on hover, default, neutral, outline, ghost and reverse variants, optional mark, arrows, rotation and stripe, plus a ribbon button. Ships in the Neoconstruct theme.
import * as React from "react"
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import {
Marker,
type MarkerProp,
} from "@/components/neoconstruct/neoconstruct-marker"
/**
* A solid triangle pointing right, the arrow `Button` shows after its label.
* It draws in the text color, is hidden from assistive tech, and takes only
* `className`.
*/
function ArrowRight({
className,
}: {
/** Extra classes for the `svg`. It is 16px square (`size-4`) by default. */
className?: string
}) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
className={cn("size-4", className)}
aria-hidden="true"
>
<polygon points="5,4 19,12 5,20" />
</svg>
)
}
/**
* A solid triangle pointing left, the arrow `Button` shows before its label.
* It draws in the text color, is hidden from assistive tech, and takes only
* `className`.
*/
function ArrowLeft({
className,
}: {
/** Extra classes for the `svg`. It is 16px square (`size-4`) by default. */
className?: string
}) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
className={cn("size-4", className)}
aria-hidden="true"
>
<polygon points="19,4 5,12 19,20" />
</svg>
)
}
/**
* Two solid triangles pointing right, the arrow `Button` shows after its
* label with `arrow="forward"`. It draws in the text color, is hidden from
* assistive tech, and takes only `className`.
*/
function DoubleArrowRight({
className,
}: {
/** Extra classes for the `svg`. It is 16px square (`size-4`) by default. */
className?: string
}) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
className={cn("size-4", className)}
aria-hidden="true"
>
<polygon points="2,4 10,12 2,20" />
<polygon points="12,4 20,12 12,20" />
</svg>
)
}
const buttonVariants = cva(
"inline-flex flex-row items-center justify-center gap-2 rounded-base font-heading text-sm tracking-wider whitespace-nowrap uppercase ring-offset-white transition-all duration-100 focus-visible:ring-2 focus-visible:ring-border focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
/**
* The look. Variants with a shadow slide into it on hover. `default`
* (the default) is poster red with a 3px border and a hard shadow.
* `noShadow` is the same without the shadow or the movement, and
* `destructive` is the same with a darker red on hover. `neutral` uses
* `secondary-background`, warm white in light mode, with a 2px border
* and the shadow; `secondary` is the same but turns gold on hover.
* `outline` is transparent with a 2px border and the shadow, and fills
* red on hover. `ghost` is bare until hover adds a border and a faint
* gold tint. `reverse` is red with no shadow, and on hover it lifts
* and gains one.
*/
variant: {
default:
"border-3 border-border bg-main text-main-foreground shadow-shadow hover:translate-x-boxShadowX hover:translate-y-boxShadowY hover:shadow-none active:translate-x-boxShadowX active:translate-y-boxShadowY active:shadow-none",
noShadow: "border-3 border-border bg-main text-main-foreground",
neutral:
"border-2 border-border bg-secondary-background text-foreground shadow-shadow hover:translate-x-boxShadowX hover:translate-y-boxShadowY hover:shadow-none",
secondary:
"border-2 border-border bg-secondary-background text-foreground shadow-shadow hover:translate-x-boxShadowX hover:translate-y-boxShadowY hover:bg-accent hover:text-accent-foreground hover:shadow-none",
ghost:
"border-2 border-transparent bg-transparent text-foreground hover:border-border hover:bg-accent/20",
destructive:
"border-3 border-border bg-main text-main-foreground shadow-shadow hover:translate-x-boxShadowX hover:translate-y-boxShadowY hover:bg-neoconstruct-red-dark hover:shadow-none",
outline:
"border-2 border-border bg-transparent text-foreground shadow-shadow hover:translate-x-boxShadowX hover:translate-y-boxShadowY hover:bg-main hover:text-main-foreground hover:shadow-none",
reverse:
"border-3 border-border bg-main text-main-foreground hover:translate-x-reverseBoxShadowX hover:translate-y-reverseBoxShadowY hover:shadow-shadow",
},
/**
* Height and text size: `default` (the default) 44px tall with 14px
* text, `sm` 36px with 12px text, `lg` 52px with 16px text, and `icon`
* a 40px square. Side padding grows from 16px on `sm` to 32px on `lg`.
*/
size: {
default: "h-11 px-6 py-2",
sm: "h-9 px-4 text-xs",
lg: "h-13 px-8 text-base",
icon: "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends
Omit<ButtonPrimitive.Props, "className">,
VariantProps<typeof buttonVariants> {
/**
* Extra classes for the button, as a string only: Base UI's function form
* is not accepted.
*/
className?: string
/**
* Constructivist mark before the label. A left arrow (`arrow="left"` or
* `"both"`) takes its place.
*/
marker?: MarkerProp
/**
* Tilts the button 2 degrees: `"left"` (or `true`) counterclockwise,
* `"right"` clockwise.
*/
rotated?: boolean | "left" | "right"
/** Adds a translucent slanted band across the button's right end. */
stripe?: boolean
/**
* Adds solid arrows: `"left"` before the label, `"right"` after it,
* `"both"` on each side, `"forward"` a double arrow after it.
*/
arrow?: "left" | "right" | "both" | "forward"
}
/**
* The Neoconstruct button: an uppercase label in the heading font on a
* bordered block with a hard offset shadow. Variants with a shadow slide into
* it on hover, as if pressed; `reverse` lifts and gains a shadow instead. It
* carries the Neoconstruct theme with it, wherever it is placed.
*
* It renders Base UI's `Button`, so it takes the same props, such as
* `disabled` and `render`. `className` takes a string.
*/
function Button({
className,
variant,
size,
marker = "none",
rotated = false,
stripe = false,
arrow,
children,
...props
}: ButtonProps) {
// Determine rotation class
const getRotationClass = () => {
if (!rotated) return ""
if (rotated === "left") return "-rotate-2"
if (rotated === "right") return "rotate-2"
return "-rotate-2" // default to left rotation
}
// Render left decoration (arrow or marker)
const renderLeftDecor = () => {
if (arrow === "left" || arrow === "both")
return <ArrowLeft className="size-4" />
return <Marker variant={marker} />
}
// Render right decoration (arrow)
const renderRightDecor = () => {
if (arrow === "right" || arrow === "both" || arrow === "forward") {
return arrow === "forward" ? (
<DoubleArrowRight className="size-4" />
) : (
<ArrowRight className="size-4" />
)
}
return null
}
return (
<ButtonPrimitive
data-slot="button"
data-theme="neoconstruct"
className={cn(
buttonVariants({ variant, size }),
getRotationClass(),
stripe && "relative overflow-hidden",
className
)}
{...props}
>
{/* Diagonal stripe overlay */}
{stripe && (
<div
className="pointer-events-none absolute top-0 right-0 h-full w-8"
aria-hidden="true"
>
<div className="absolute -top-2 -right-4 h-16 w-12 -skew-x-[20deg] bg-border/30" />
</div>
)}
{renderLeftDecor()}
{children}
{renderRightDecor()}
</ButtonPrimitive>
)
}
/**
* A banner-shaped call to action for a hero: a Neoconstruct button with a
* ribbon tail on each side and a mark before the label.
*
* It renders a native `button`, `type="button"` by default, inside a wrapper
* that draws the tails. `className` and the other props land on the
* `button`, not the wrapper. The tails are fixed-size triangles in the
* `main` color.
*/
function RibbonButton({
className,
children,
marker = "arrow",
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
/** The mark before the label; "none" removes it. Defaults to "arrow". */
marker?: MarkerProp
}) {
return (
<div className="group relative inline-flex">
{/* Left ribbon tail */}
<div
className="absolute top-1/2 left-0 h-0 w-0 -translate-x-full -translate-y-1/2"
style={{
borderTop: "22px solid transparent",
borderBottom: "22px solid transparent",
borderRight: "12px solid var(--main)",
}}
/>
<button
type="button"
data-theme="neoconstruct"
className={cn(
"relative inline-flex items-center justify-center gap-2",
"bg-main text-main-foreground",
"px-6 py-3 font-heading text-sm tracking-wider uppercase",
"border-2 border-border",
"shadow-shadow",
"hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-shadow-sm",
"active:translate-x-boxShadowX active:translate-y-boxShadowY active:shadow-none",
"transition-all duration-100",
"disabled:pointer-events-none disabled:opacity-50",
className
)}
{...props}
>
<Marker variant={marker} />
{children}
</button>
{/* Right ribbon tail */}
<div
className="absolute top-1/2 right-0 h-0 w-0 translate-x-full -translate-y-1/2"
style={{
borderTop: "22px solid transparent",
borderBottom: "22px solid transparent",
borderLeft: "12px solid var(--main)",
}}
/>
</div>
)
}
export {
Button,
RibbonButton,
buttonVariants,
ArrowRight,
ArrowLeft,
DoubleArrowRight,
}
pnpm dlx shadcn add @01orbital/neoconstruct-buttonnpx shadcn add @01orbital/neoconstruct-buttonyarn shadcn add @01orbital/neoconstruct-buttonbunx --bun shadcn add @01orbital/neoconstruct-buttonFree under the MIT license. Add the @01orbital registry to components.json once (install guide), then run the command.
It lands at components/neoconstruct/neoconstruct-button.tsx, as source you own and edit.
import {
Button,
RibbonButton,
} from "@/components/neoconstruct/neoconstruct-button"
export default function ButtonDemo() {
return (
<div className="flex flex-col items-start gap-6">
{/* Default button with a leading marker */}
<Button marker="arrow">Get started</Button>
{/* Ribbon banner button - the poster CTA */}
<RibbonButton>Sign up</RibbonButton>
{/* Marker variants */}
<div className="flex flex-wrap gap-4">
<Button marker="wedge">Wedge</Button>
<Button marker="disc">Disc</Button>
<Button marker="band">Band</Button>
<Button marker="square">Square</Button>
<Button marker="arrow">Arrow</Button>
</div>
{/* Regular variants */}
<div className="flex flex-wrap gap-4">
<Button variant="default">Default</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
</div>
</div>
)
}Neoconstruct Button is built from 5 parts. In the example they nest like this:
Button
RibbonButtonButton: The Neoconstruct button: an uppercase label in the heading font on a bordered block with a hard offset shadow. Variants with a shadow slide into it on hover, as if pressed; reverse lifts and gains a shadow instead. It carries the Neoconstruct theme with it, wherever it is placed.RibbonButton: A banner-shaped call to action for a hero: a Neoconstruct button with a ribbon tail on each side and a mark before the label.ArrowRight: A solid triangle pointing right, the arrow Button shows after its label. It draws in the text color, is hidden from assistive tech, and takes only className.ArrowLeft: A solid triangle pointing left, the arrow Button shows before its label. It draws in the text color, is hidden from assistive tech, and takes only className.DoubleArrowRight: Two solid triangles pointing right, the arrow Button shows after its label with arrow="forward". It draws in the text color, is hidden from assistive tech, and takes only className.The Neoconstruct button: an uppercase label in the heading font on a bordered block with a hard offset shadow. Variants with a shadow slide into it on hover, as if pressed; reverse lifts and gains a shadow instead. It carries the Neoconstruct theme with it, wherever it is placed.
It renders Base UI's Button, so it takes the same props, such as disabled and render. className takes a string.
Prop
Type
It also accepts the props of the Base UI part it wraps, and the HTML attributes of the element it renders.
A banner-shaped call to action for a hero: a Neoconstruct button with a ribbon tail on each side and a mark before the label.
It renders a native button, type="button" by default, inside a wrapper that draws the tails. className and the other props land on the button, not the wrapper. The tails are fixed-size triangles in the main color.
Prop
Type
It also accepts the HTML attributes of the element it renders.
A solid triangle pointing right, the arrow Button shows after its label. It draws in the text color, is hidden from assistive tech, and takes only className.
Prop
Type
A solid triangle pointing left, the arrow Button shows before its label. It draws in the text color, is hidden from assistive tech, and takes only className.
Prop
Type
Two solid triangles pointing right, the arrow Button shows after its label with arrow="forward". It draws in the text color, is hidden from assistive tech, and takes only className.
Prop
Type
npm: @base-ui/react, class-variance-authority, cn. The CLI installs them for you.
Registry pieces it installs with it:
font-unboundedNeoconstruct Badge
Uppercase Oswald badge in gold, red, neutral and subtle tones, plus a ribbon badge with a leading mark. Ships in the Neoconstruct theme.
Neoconstruct Marker
Neoconstruct's ruler-and-compass marks: wedge, slash, disc, ring, plus, square, bar and star, plus a zero-padded numeral. Ships in the Neoconstruct theme.