Two settings cards with an edge that looks machined instead of drawn. Each card wraps its content in five nested one pixel borders that alternate light and dark, so the rim catches light like a bevel. Sections inside are split by two tone grooves, and the buttons use the same idea: a thin gradient rim around a gradient face that darkens when pressed. The light card is a working team invite form with a member list. The dark card shows a storage breakdown with an upgrade button. The two sit side by side on desktop and stack on phones.
// Adapted from Cult UI (https://www.cult-ui.com/docs/components/texture-card)
// Copyright (c) 2023 Jordan-Gilliam. MIT License: https://we.inc/components/licenses
import { useState, type ReactNode } from "react";
import { ArrowRight, Check, Copy, HardDrive, Mail } from "lucide-react";
const roles = ["Editor", "Viewer", "Admin"];
const members = [
{ name: "Solveig Aalto", email: "[email protected]", role: "Admin", tint: "bg-amber-200 text-amber-900" },
{ name: "Dev Malhotra", email: "[email protected]", role: "Editor", tint: "bg-sky-200 text-sky-900" },
];
const usage = [
{ label: "Manuscripts", amount: "18.2 GB", share: 46, bar: "bg-indigo-400" },
{ label: "Cover artwork", amount: "9.6 GB", share: 24, bar: "bg-fuchsia-400" },
{ label: "Audio proofs", amount: "4.1 GB", share: 10, bar: "bg-amber-300" },
];
/**
* Five one pixel borders nested inside each other, alternating light and dark,
* so the edge reads as a bevelled rim instead of a single line.
*/
function TextureCard({ dark = false, children }: { dark?: boolean; children: ReactNode }) {
return (
<div className={`rounded-[24px] border bg-gradient-to-b shadow-[0_18px_40px_-24px_rgba(0,0,0,0.45)] ${dark ? "border-stone-950/60 from-neutral-800 to-neutral-900" : "border-white/60 from-neutral-100 to-white/70"}`}>
<div className={`rounded-[23px] border ${dark ? "border-neutral-900/80" : "border-black/10"}`}>
<div className={`rounded-[22px] border ${dark ? "border-neutral-950" : "border-white/50"}`}>
<div className={`rounded-[21px] border ${dark ? "border-neutral-900/70" : "border-neutral-950/20"}`}>
<div className={`w-full rounded-[20px] border ${dark ? "border-neutral-700/50" : "border-white/50"}`}>{children}</div>
</div>
</div>
</div>
</div>
);
}
/** A two tone line: a light edge above a dark one, like a groove cut into the surface. */
function Groove({ dark = false }: { dark?: boolean }) {
return <div aria-hidden="true" className={`border border-x-transparent ${dark ? "border-b-neutral-700/50 border-t-neutral-950" : "border-b-neutral-300/50 border-t-neutral-50"}`} />;
}
/** A button built the same way: a gradient rim one pixel thick around a gradient face. */
function TextureButton({ variant = "primary", children, onClick, type = "button" }: { variant?: "primary" | "accent" | "secondary"; children: ReactNode; onClick?: () => void; type?: "button" | "submit" }) {
const rim = {
primary: "border-black/10 bg-gradient-to-b from-black/70 to-black",
accent: "border-black/10 bg-gradient-to-b from-indigo-300/90 to-indigo-500",
secondary: "border-black/20 bg-white/50",
}[variant];
const face = {
primary: "bg-gradient-to-b from-neutral-800 to-black text-white group-hover:from-stone-700 group-hover:to-neutral-800 group-active:from-black group-active:to-black",
accent: "bg-gradient-to-b from-indigo-500 to-indigo-700 text-white group-hover:from-indigo-500 group-hover:to-indigo-600 group-active:from-indigo-700 group-active:to-indigo-700",
secondary: "bg-gradient-to-b from-neutral-100 to-neutral-200/60 text-neutral-900 group-hover:from-neutral-200/70 group-hover:to-neutral-300/70 group-active:from-neutral-300 group-active:to-neutral-300",
}[variant];
return (
<button type={type} onClick={onClick} className={`group w-full rounded-[12px] border p-px outline-none transition duration-300 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 ${rim}`}>
<span className={`flex h-10 w-full items-center justify-center gap-2 rounded-[10px] px-4 text-sm font-medium transition duration-300 ${face}`}>{children}</span>
</button>
);
}
export default function LayeredBevelBorderCardsWithTexturedButtons() {
const [email, setEmail] = useState("");
const [role, setRole] = useState(roles[0]);
const [sent, setSent] = useState("");
const [copied, setCopied] = useState(false);
return (
<section className="bg-stone-200 px-4 py-12 sm:px-6 sm:py-16">
<div className="mx-auto grid max-w-4xl items-start gap-6 md:grid-cols-2">
<TextureCard>
<div className="px-6 pb-4 pt-6">
<div className="mb-4 grid h-11 w-11 place-items-center rounded-xl bg-gradient-to-b from-white to-neutral-200 shadow-[0_1px_0_#fff_inset,0_1px_3px_rgba(0,0,0,0.2)] ring-1 ring-black/10">
<Mail className="h-5 w-5 text-neutral-800" aria-hidden="true" />
</div>
<h2 className="text-lg font-semibold leading-tight text-neutral-900">Invite your team</h2>
<p className="mt-1 text-sm text-neutral-600">Harbour Press has 2 of 5 seats in use.</p>
</div>
<Groove />
<form
className="space-y-3 px-6 py-4"
onSubmit={(e) => {
e.preventDefault();
if (!email.trim()) return;
setSent(email.trim());
setEmail("");
}}
>
<div className="grid grid-cols-[1fr_auto] gap-2">
<div>
<label htmlFor="bevel-invite-email" className="mb-1 block text-xs font-medium text-neutral-700">
Email address
</label>
<input
id="bevel-invite-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="[email protected]"
className="h-10 w-full rounded-[10px] border border-black/15 bg-white/80 px-3 text-sm text-neutral-900 shadow-[0_1px_2px_rgba(0,0,0,0.06)_inset] outline-none placeholder:text-neutral-500 focus-visible:ring-2 focus-visible:ring-indigo-500"
/>
</div>
<div>
<label htmlFor="bevel-invite-role" className="mb-1 block text-xs font-medium text-neutral-700">
Role
</label>
<select id="bevel-invite-role" value={role} onChange={(e) => setRole(e.target.value)} className="h-10 rounded-[10px] border border-black/15 bg-white/80 px-2 text-sm text-neutral-900 outline-none focus-visible:ring-2 focus-visible:ring-indigo-500">
{roles.map((r) => (
<option key={r}>{r}</option>
))}
</select>
</div>
</div>
<TextureButton type="submit">
Send invite <ArrowRight className="h-4 w-4" aria-hidden="true" />
</TextureButton>
<p role="status" className="text-sm text-emerald-700 empty:hidden">
{sent && `Invite sent to ${sent} as ${role}.`}
</p>
</form>
<Groove />
<ul className="px-6 py-3">
{members.map((m) => (
<li key={m.email} className="flex items-center gap-3 py-2">
<span className={`grid h-8 w-8 shrink-0 place-items-center rounded-full text-xs font-bold ${m.tint}`}>{m.name.split(" ").map((w) => w[0]).join("")}</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-neutral-900">{m.name}</p>
<p className="truncate text-xs text-neutral-600">{m.email}</p>
</div>
<span className="rounded-full border border-black/10 bg-white/70 px-2 py-0.5 text-xs font-medium text-neutral-700">{m.role}</span>
</li>
))}
</ul>
<Groove />
<div className="rounded-b-[20px] bg-neutral-200/40 px-6 py-4">
<TextureButton
variant="secondary"
onClick={() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1800);
}}
>
{copied ? <Check className="h-4 w-4" aria-hidden="true" /> : <Copy className="h-4 w-4" aria-hidden="true" />}
{copied ? "Link copied" : "Copy invite link"}
</TextureButton>
</div>
</TextureCard>
<TextureCard dark>
<div className="px-6 pb-4 pt-6">
<div className="mb-4 grid h-11 w-11 place-items-center rounded-xl bg-gradient-to-b from-neutral-700 to-neutral-800 shadow-[0_1px_0_rgba(255,255,255,0.12)_inset,0_1px_3px_rgba(0,0,0,0.5)] ring-1 ring-black/40">
<HardDrive className="h-5 w-5 text-neutral-100" aria-hidden="true" />
</div>
<h2 className="text-lg font-semibold leading-tight text-neutral-100">Storage</h2>
<p className="mt-1 text-sm text-neutral-400">31.9 GB of 40 GB used on the Studio plan.</p>
</div>
<Groove dark />
<div className="px-6 py-5">
<div className="flex h-3 overflow-hidden rounded-full bg-neutral-950 shadow-[0_1px_0_rgba(255,255,255,0.08)]" role="img" aria-label="Storage is 80 percent full">
{usage.map((u) => (
<span key={u.label} className={`h-full border-r border-neutral-950 ${u.bar}`} style={{ width: `${u.share}%` }} />
))}
</div>
<ul className="mt-4 space-y-2.5">
{usage.map((u) => (
<li key={u.label} className="flex items-center gap-2.5 text-sm">
<span className={`h-2.5 w-2.5 rounded-sm ${u.bar}`} aria-hidden="true" />
<span className="flex-1 text-neutral-300">{u.label}</span>
<span className="tabular-nums text-neutral-100">{u.amount}</span>
</li>
))}
</ul>
</div>
<Groove dark />
<div className="space-y-3 rounded-b-[20px] bg-black/20 px-6 py-4">
<p className="text-sm text-neutral-300">
Move to <span className="font-medium text-white">Press</span> for 200 GB and unlimited seats at $29 a month.
</p>
<TextureButton variant="accent">Upgrade plan</TextureButton>
</div>
</TextureCard>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/layered-bevel-border-cards-with-textured-buttons.json
A single border is one colour all the way round. Stacking borders one pixel apart lets each ring have its own colour and opacity, which produces the light, dark, light banding of a bevelled edge and stays crisp at any zoom level.
No. It adds four plain wrapper elements per card with no filters or blur. It is cheaper to paint than a large blurred shadow.
Each inner layer sits one pixel inside the last. Reducing the radius by the same amount keeps the curves concentric, so the gap between rings is even around the corners.
No. Submitting shows a confirmation line and clears the field. Put your own request inside the form's `onSubmit` handler.
Yes. `TextureButton` is self contained. It is full width by default; wrap it in a sized element or remove `w-full` to make it inline.
Adapted from Texture Card in Cult UI, used under its open-source licence. Licences and credits
Related: Settings Panel with Toggle Switches and Save Bar, Dark Sign-In Form with Show Password Toggle, Raised Buttons with Pressed Edge and Loading State.
More cards or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.