cardsclient

carousel

slides that move on by themselves or when you nudge them, with a thin line showing time left.

the quiet art
of long-form writing

8 min read · writing

1 / 3

installation

new to blurrow? this sets up your project in one go: the package, the styles and the font. already done it? skip straight to usage. the installation page has the details.

bash
npx @blurrow/ui init
›rather own the code? copy the source instead

let the cli copy it, along with anything it needs:

bash
npx @blurrow/ui add carousel

or by hand: drop these files into components/ui and import from there. carousel leans on Button.tsx and CardShell.tsx, so those come along too.

components/ui/Carousel.tsxtsx
"use client";
import React, { useState, useEffect, useRef } from "react";
import { Button } from "./Button";
import { CardShell } from "./CardShell";
interface CarouselProps {
items: React.ReactNode[];
autoPlay?: boolean;
interval?: number;
className?: string;
}
const SLIDE_MS = 680;
interface Slot {
itemIndex: number;
key: string;
role: "current" | "prev";
}
export function Carousel({
items,
autoPlay = false,
interval = 4000,
className = "",
}: CarouselProps) {
const [slots, setSlots] = useState<Slot[]>([
{ itemIndex: 0, key: "s-0-0", role: "current" },
]);
const [direction, setDirection] = useState<"left" | "right">("right");
const [currentIdx, setCurrentIdx] = useState(0);
// Autoplay never moves content out from under someone looking at it
const [paused, setPaused] = useState(false);
const currentIdxRef = useRef(0);
const lockedRef = useRef(false);
const tcRef = useRef(0);
const cleanupRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const go = (nextIdx: number, dir: "left" | "right") => {
if (lockedRef.current || nextIdx === currentIdxRef.current) return;
const prevIdx = currentIdxRef.current;
const tc = ++tcRef.current;
lockedRef.current = true;
currentIdxRef.current = nextIdx;
setDirection(dir);
setCurrentIdx(nextIdx);
setSlots([
{ itemIndex: nextIdx, key: `c-${nextIdx}-${tc}`, role: "current" },
{ itemIndex: prevIdx, key: `p-${prevIdx}-${tc}`, role: "prev" },
]);
if (cleanupRef.current) clearTimeout(cleanupRef.current);
cleanupRef.current = setTimeout(() => {
setSlots([{ itemIndex: nextIdx, key: `c-${nextIdx}-${tc}`, role: "current" }]);
lockedRef.current = false;
}, SLIDE_MS + 20);
};
const goNext = () => go((currentIdxRef.current + 1) % items.length, "right");
const goPrev = () => go((currentIdxRef.current - 1 + items.length) % items.length, "left");
const goNextRef = useRef(goNext);
goNextRef.current = goNext;
useEffect(() => {
if (!autoPlay || paused) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const id = setInterval(() => goNextRef.current(), interval);
return () => clearInterval(id);
}, [autoPlay, interval, paused]);
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowRight") { e.preventDefault(); goNextRef.current(); }
if (e.key === "ArrowLeft") { e.preventDefault(); goPrev(); }
};
useEffect(() => () => { if (cleanupRef.current) clearTimeout(cleanupRef.current); }, []);
// ── Swipe / pointer drag ──────────────────────────────────────────
const dragRef = useRef<{ x: number; y: number; active: boolean }>({ x: 0, y: 0, active: false });
const THRESHOLD = 48;
const onPointerDown = (e: React.PointerEvent) => {
dragRef.current = { x: e.clientX, y: e.clientY, active: true };
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
};
const onPointerUp = (e: React.PointerEvent) => {
if (!dragRef.current.active) return;
const dx = e.clientX - dragRef.current.x;
const dy = e.clientY - dragRef.current.y;
dragRef.current.active = false;
if (Math.abs(dx) > THRESHOLD && Math.abs(dx) > Math.abs(dy) * 1.5) {
dx < 0 ? goNextRef.current() : goPrev();
}
};
const onPointerCancel = () => { dragRef.current.active = false; };
/* A plain slide: the incoming and outgoing slides travel together as
one strip, a full width each. The curve is a strong ease-out, so the
strip moves the instant you click and then settles — an ease-in-out
hesitates first, which reads as lag. Driven by the Web Animations API
on mount, so it never depends on a stylesheet. */
const slide = (role: Slot["role"]) => (el: HTMLDivElement | null) => {
if (!el || el.dataset.animated || slots.length < 2) return;
el.dataset.animated = "1";
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const sign = direction === "right" ? 1 : -1;
const frames =
role === "current"
? [{ transform: `translateX(${sign * 100}%)` }, { transform: "translateX(0)" }]
: [{ transform: "translateX(0)" }, { transform: `translateX(${-sign * 100}%)` }];
el.animate(frames, { duration: SLIDE_MS, easing: "cubic-bezier(0.22, 1, 0.36, 1)", fill: "both" });
};
const controls = (
<div className="w-full flex items-center justify-between">
<div className="flex items-center gap-3 pl-1.5">
{/* Dots sit in a well; the active one fills over the autoplay
interval, and the fill holds while autoplay is paused */}
<div
className="flex items-center gap-1 rounded-full p-[5px]"
style={{ background: "var(--well)", boxShadow: "var(--well-shadow)" }}
>
{items.map((_, i) => {
const active = i === currentIdx;
return (
<button
key={i}
onClick={() => go(i, i > currentIdxRef.current ? "right" : "left")}
className={[
"relative h-1.5 rounded-full overflow-hidden cursor-pointer",
"transition-[width,background] duration-300 ease-[var(--ease-out-soft)]",
"after:absolute after:-inset-2 after:content-['']",
active ? "w-6 bg-neutral-900/15" : "w-1.5 bg-neutral-900/20 hover:bg-neutral-900/40",
].join(" ")}
aria-label={`Go to slide ${i + 1}`}
aria-current={active ? "true" : undefined}
>
{active && (
<span
key={`${currentIdx}-${autoPlay}`}
aria-hidden
className={["absolute inset-0 rounded-full origin-left", autoPlay ? "carousel-progress" : ""].join(" ")}
style={{
background: "var(--face-dark)",
animationDuration: `${interval}ms`,
animationPlayState: paused ? "paused" : "running",
}}
/>
)}
</button>
);
})}
</div>
<span className="text-[12px] font-medium text-neutral-600 tabular-nums">
{currentIdx + 1}
<span className="text-neutral-400"> / {items.length}</span>
</span>
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" square onClick={goPrev} aria-label="Previous slide">
<svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</Button>
<Button variant="secondary" size="sm" square onClick={goNext} aria-label="Next slide">
<svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</Button>
</div>
</div>
);
/* Built on the card chassis like every other surface: slides are the
face, controls live on the rail below it. */
return (
<section
aria-roledescription="carousel"
aria-label="Carousel"
onKeyDown={onKeyDown}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onFocus={() => setPaused(true)}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) setPaused(false);
}}
className={className}
>
<CardShell rail={controls}>
<div
className="relative w-full overflow-hidden cursor-grab active:cursor-grabbing touch-pan-y"
style={{ minHeight: 300 }}
aria-live={autoPlay && !paused ? "off" : "polite"}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onPointerCancel={onPointerCancel}
>
{slots.map((slot) => (
<div
key={slot.key}
role="group"
aria-roledescription="slide"
aria-label={`${slot.itemIndex + 1} of ${items.length}`}
aria-hidden={slot.role === "prev"}
ref={slide(slot.role)}
className={["absolute inset-0 will-change-transform [backface-visibility:hidden]", slot.role === "prev" ? "pointer-events-none" : ""].join(" ")}
>
{items[slot.itemIndex]}
</div>
))}
</div>
</CardShell>
</section>
);
}
type SlideTheme = "dark" | "light" | "tint";
interface CarouselSlideProps {
theme?: SlideTheme;
/** Background for the "tint" theme */
tint?: string;
icon?: React.ReactNode;
title: React.ReactNode;
meta?: React.ReactNode;
}
/* A ready-made slide in the cards' vocabulary: the card's own material,
a raised icon tile, and the same fading dot grid as empty media. */
export function CarouselSlide({ theme = "dark", tint = "#8A8E7A", icon, title, meta }: CarouselSlideProps) {
const dark = theme !== "light";
return (
<div
className="relative w-full h-full flex flex-col justify-end gap-6 p-8 overflow-hidden select-none"
style={{
background: theme === "dark" ? "var(--card-dark)" : theme === "light" ? "var(--card-light)" : tint,
}}
>
<div
aria-hidden
className="absolute inset-y-0 right-0 w-3/5"
style={{
backgroundImage: `radial-gradient(${
theme === "tint" ? "rgba(255,255,255,0.22)" : dark ? "rgba(255,255,255,0.09)" : "rgba(17,17,17,0.08)"
} 1px, transparent 1px)`,
backgroundSize: "12px 12px",
maskImage: "radial-gradient(60% 70% at 70% 50%, #000 0%, transparent 100%)",
WebkitMaskImage: "radial-gradient(60% 70% at 70% 50%, #000 0%, transparent 100%)",
}}
/>
{icon && (
<div
aria-hidden
className={[
"relative w-9 h-9 rounded-[11px] flex items-center justify-center mb-auto",
dark ? "text-neutral-100" : "text-neutral-700",
].join(" ")}
style={{
background: dark ? "var(--tile-dark)" : "var(--face-light)",
boxShadow: dark ? "var(--tile-dark-shadow)" : "var(--tile-light-shadow)",
}}
>
{icon}
</div>
)}
<div className="relative">
<p
className={[
"text-[22px] font-medium leading-[1.2] tracking-[-0.015em] mb-2",
dark ? "text-white" : "text-neutral-900",
].join(" ")}
>
{title}
</p>
{meta && (
<p className={["text-[13px]", theme === "tint" ? "text-white/65" : dark ? "text-neutral-400" : "text-neutral-500"].join(" ")}>
{meta}
</p>
)}
</div>
</div>
);
}
components/ui/Button.tsxtsx
"use client";
import React, { useState, useRef, useCallback } from "react";
type ButtonVariant = "primary" | "secondary" | "ghost" | "outline";
type ButtonSize = "sm" | "md" | "lg" | "icon";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
children: React.ReactNode;
fullWidth?: boolean;
/** Surface the button sits on. On dark cards, secondary/outline switch to a
dark face so they don't glare against the card. */
tone?: "light" | "dark";
/** Equal width and height, for icon-only layered buttons. */
square?: boolean;
}
/* Layered buttons are a raised face seated in a machined bezel. The bezel
never moves; the face lifts on hover and sinks flush when pressed. */
const shellConfig: Record<
string,
{
bezel: string;
bezelShadow: string;
face: string;
faceShadow: string;
faceHoverShadow: string;
facePressedShadow: string;
text: string;
textShadow: string;
fill: string;
}
> = {
primary: {
bezel: "var(--bezel-dark)",
bezelShadow: "var(--bezel-dark-shadow)",
face: "var(--face-dark)",
faceShadow: "var(--face-dark-shadow)",
faceHoverShadow:
"0 0 3px rgba(0,0,0,0.35), 0 2px 3px rgba(0,0,0,0.45), 0 4px 8px -2px rgba(0,0,0,0.35), inset 0 2px 3px -2px rgba(255,255,255,0.135), inset 0 -2px 3px -2px rgba(0,0,0,0.25)",
facePressedShadow: "var(--face-dark-pressed)",
text: "text-white",
textShadow: "0 -1px 0 rgba(0,0,0,0.45)",
fill: "bg-white/[0.05]",
},
secondary: {
bezel: "var(--bezel-light)",
bezelShadow: "var(--bezel-light-shadow)",
face: "var(--face-light)",
faceShadow: "var(--face-light-shadow)",
faceHoverShadow:
"0 0 3px rgba(17,17,17,0.136), 0 2px 3px rgba(17,17,17,0.1), 0 4px 8px -2px rgba(17,17,17,0.08), inset 0 2px 3px -2px rgba(255,255,255,1), inset 0 -2px 3px -2px rgba(17,17,17,0.035)",
facePressedShadow: "var(--face-light-pressed)",
text: "text-neutral-800",
textShadow: "0 1px 0 rgba(255,255,255,0.9)",
fill: "bg-neutral-900/[0.025]",
},
outline: {
bezel: "linear-gradient(180deg, rgba(255,255,255,0.5) 0%, rgba(17,17,17,0.04) 100%)",
bezelShadow:
"0 0 0 0.5px rgba(17,17,17,0.048), inset 0 2px 3px -2px rgba(255,255,255,0.45)",
face: "var(--face-light)",
faceShadow:
"0 0 3px rgba(17,17,17,0.102), 0 1px 1px rgba(17,17,17,0.06), inset 0 2px 3px -2px rgba(255,255,255,1)",
faceHoverShadow:
"0 0 3px rgba(17,17,17,0.136), 0 1px 2px rgba(17,17,17,0.08), 0 3px 6px -2px rgba(17,17,17,0.06), inset 0 2px 3px -2px rgba(255,255,255,1)",
facePressedShadow: "var(--face-light-pressed)",
text: "text-neutral-700",
textShadow: "0 1px 0 rgba(255,255,255,0.9)",
fill: "bg-neutral-900/[0.025]",
},
};
/* Secondary on a dark surface: a raised face a few steps lighter than the
card — present, but in the same family of tones. */
const darkSecondary = {
bezel: "linear-gradient(180deg, #2a2a2a 0%, #161616 100%)",
bezelShadow:
"0 0 0 0.5px rgba(0,0,0,0.3), 0 1px 2px rgba(0,0,0,0.35), inset 0 2px 3px -2px rgba(255,255,255,0.053)",
face: "linear-gradient(180deg, #353535 0%, #2a2a2a 55%, #242424 100%)",
faceShadow:
"0 0 3px rgba(0,0,0,0.35), 0 1px 1.5px rgba(0,0,0,0.4), inset 0 2px 3px -2px rgba(255,255,255,0.09), inset 0 -2px 3px -2px rgba(0,0,0,0.2)",
faceHoverShadow:
"0 0 3px rgba(0,0,0,0.35), 0 2px 3px rgba(0,0,0,0.45), 0 4px 8px -2px rgba(0,0,0,0.35), inset 0 2px 3px -2px rgba(255,255,255,0.12), inset 0 -2px 3px -2px rgba(0,0,0,0.2)",
facePressedShadow: "var(--face-dark-pressed)",
text: "text-neutral-100",
textShadow: "0 -1px 0 rgba(0,0,0,0.4)",
fill: "bg-white/[0.04]",
};
/* Flat buttons (ghost, icon) are a single face without a bezel. */
const flatConfig: Record<
ButtonVariant,
{
bg: string;
background?: string;
fill: string;
shadow: string;
hoverShadow: string;
pressedShadow: string;
}
> = {
primary: {
bg: "text-white",
background: "var(--face-dark)",
fill: "bg-white/[0.08]",
shadow: "var(--face-dark-shadow)",
hoverShadow:
"0 0 3px rgba(0,0,0,0.35), 0 2px 4px rgba(0,0,0,0.4), 0 6px 14px -4px rgba(0,0,0,0.35), inset 0 2px 3px -2px rgba(255,255,255,0.135)",
pressedShadow: "var(--face-dark-pressed)",
},
secondary: {
bg: "text-neutral-800",
background: "var(--face-light)",
fill: "bg-neutral-900/[0.035]",
shadow: "var(--face-light-shadow)",
hoverShadow:
"0 0 3px rgba(17,17,17,0.136), 0 2px 4px rgba(17,17,17,0.08), 0 6px 14px -4px rgba(17,17,17,0.1), inset 0 2px 3px -2px rgba(255,255,255,1)",
pressedShadow: "var(--face-light-pressed)",
},
ghost: {
bg: "bg-transparent text-neutral-500 hover:text-neutral-800",
fill: "bg-neutral-900/[0.05]",
shadow: "none",
hoverShadow: "inset 0 0 2px rgba(17,17,17,0.09)",
pressedShadow: "inset 0 1px 2px rgba(17,17,17,0.08)",
},
outline: {
bg: "bg-transparent text-neutral-700",
fill: "bg-neutral-900/[0.035]",
shadow: "inset 0 0 2px rgba(17,17,17,0.15)",
hoverShadow:
"inset 0 0 2px rgba(17,17,17,0.24), 0 1px 2px rgba(17,17,17,0.05)",
pressedShadow:
"inset 0 0 2px rgba(17,17,17,0.24), inset 0 1px 2px rgba(17,17,17,0.06)",
},
};
/* Bezel radius minus bezel padding = face radius, so the curves stay concentric. */
const shellRadius: Record<ButtonSize, string> = {
sm: "rounded-[12px] p-[3px]",
md: "rounded-[13px] p-[3px]",
lg: "rounded-[15px] p-[3.5px]",
icon: "",
};
const faceRadius: Record<ButtonSize, string> = {
sm: "rounded-[9px]",
md: "rounded-[10px]",
lg: "rounded-[11.5px]",
icon: "",
};
const innerPadding: Record<ButtonSize, string> = {
sm: "px-4 py-2 text-[13px]",
md: "px-5 py-2.5 text-sm",
lg: "px-7 py-3 text-[15px]",
icon: "w-7 h-7 p-0 text-[13px]",
};
const squarePadding: Record<ButtonSize, string> = {
sm: "w-9 h-9 p-0 text-[13px]",
md: "w-10 h-10 p-0 text-sm",
lg: "w-12 h-12 p-0 text-[15px]",
icon: "",
};
const flatSizeStyles: Record<ButtonSize, string> = {
sm: "px-4 py-2.5 text-[13px] rounded-[10px]",
md: "px-5 py-3 text-sm rounded-[10px]",
lg: "px-7 py-3.5 text-[15px] rounded-xl",
icon: "w-8 h-8 p-0 rounded-[10px] text-[13px]",
};
export function Button({
variant = "primary",
size = "md",
fullWidth = false,
tone = "light",
square = false,
className = "",
children,
disabled,
onClick,
onPointerDown,
onPointerUp,
onPointerLeave,
onPointerCancel,
onPointerEnter,
style,
...rest
}: ButtonProps) {
const [pressed, setPressed] = useState(false);
const [hovered, setHovered] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/* The press state is the button's own; handlers passed in run alongside
it rather than replacing it */
const handlePointerDown = useCallback(
(e: React.PointerEvent<HTMLButtonElement>) => {
if (timerRef.current) clearTimeout(timerRef.current);
setPressed(true);
onPointerDown?.(e);
},
[onPointerDown],
);
const handlePointerUp = useCallback(
(e: React.PointerEvent<HTMLButtonElement>) => {
timerRef.current = setTimeout(() => setPressed(false), 150);
onPointerUp?.(e);
},
[onPointerUp],
);
const handlePointerCancel = useCallback(
(e: React.PointerEvent<HTMLButtonElement>) => {
timerRef.current = setTimeout(() => setPressed(false), 150);
onPointerCancel?.(e);
},
[onPointerCancel],
);
const handlePointerLeave = useCallback(
(e: React.PointerEvent<HTMLButtonElement>) => {
setHovered(false);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setPressed(false), 150);
onPointerLeave?.(e);
},
[onPointerLeave],
);
const layered = variant !== "ghost" && size !== "icon";
const handlers = {
onClick,
onPointerDown: handlePointerDown,
onPointerUp: handlePointerUp,
onPointerLeave: handlePointerLeave,
onPointerCancel: handlePointerCancel,
onPointerEnter: (e: React.PointerEvent<HTMLButtonElement>) => {
setHovered(true);
onPointerEnter?.(e);
},
};
if (layered) {
const cfg =
tone === "dark" && (variant === "secondary" || variant === "outline")
? darkSecondary
: shellConfig[variant];
const faceShadow = pressed
? cfg.facePressedShadow
: hovered
? cfg.faceHoverShadow
: cfg.faceShadow;
return (
<button
data-btn
className={[
"group/btn relative inline-flex cursor-pointer select-none whitespace-nowrap",
shellRadius[size],
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900",
"disabled:opacity-40 disabled:pointer-events-none",
fullWidth ? "w-full" : "",
className,
]
.filter(Boolean)
.join(" ")}
style={{
background: cfg.bezel,
boxShadow: cfg.bezelShadow,
...style,
}}
disabled={disabled}
{...handlers}
{...rest}
>
<span
className={[
"btn-face relative overflow-hidden flex items-center justify-center gap-1.5 font-medium",
faceRadius[size],
cfg.text,
square ? squarePadding[size] : ["w-full", innerPadding[size]].join(" "),
].join(" ")}
style={{
background: cfg.face,
boxShadow: faceShadow,
textShadow: cfg.textShadow,
transform: pressed
? "translateY(0.5px) scale(0.985)"
: hovered
? "translateY(-0.5px)"
: "none",
transition:
"transform 240ms var(--ease-spring), box-shadow 200ms ease-out",
}}
>
<span
aria-hidden="true"
className={[
"pointer-events-none absolute inset-0 origin-bottom transition-transform duration-300 ease-out",
pressed
? "scale-y-100"
: "scale-y-0 group-hover/btn:scale-y-100",
cfg.fill,
].join(" ")}
/>
<span className="btn-icon-nudge relative z-10 inline-flex items-center gap-1.5">
{children}
</span>
</span>
</button>
);
}
const cfg = flatConfig[variant];
const currentShadow = pressed
? cfg.pressedShadow
: hovered
? cfg.hoverShadow
: cfg.shadow;
return (
<button
data-btn
className={[
"btn-face group/btn relative overflow-hidden inline-flex items-center justify-center gap-1.5 font-medium cursor-pointer select-none whitespace-nowrap",
"transition-colors duration-200 ease-out",
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900",
"disabled:opacity-40 disabled:pointer-events-none",
cfg.bg,
flatSizeStyles[size],
fullWidth ? "w-full" : "",
className,
]
.filter(Boolean)
.join(" ")}
style={{
background: cfg.background,
boxShadow: currentShadow,
transform: pressed
? "translateY(0.5px) scale(0.97)"
: hovered && cfg.background
? "translateY(-0.5px)"
: "none",
transition:
"transform 240ms var(--ease-spring), box-shadow 200ms ease-out, color 200ms ease-out",
...style,
}}
disabled={disabled}
{...handlers}
{...rest}
>
<span
aria-hidden="true"
className={[
"pointer-events-none absolute inset-0 origin-bottom transition-transform duration-300 ease-out",
pressed ? "scale-y-100" : "scale-y-0 group-hover/btn:scale-y-100",
cfg.fill,
].join(" ")}
/>
<span className="relative z-10 inline-flex items-center gap-1.5">
{children}
</span>
</button>
);
}
components/ui/CardShell.tsxtsx
import React from "react";
type Theme = "light" | "dark";
interface CardShellProps {
theme?: Theme;
/** Controls and context: actions, attribution, deltas. Lives on the chassis. */
rail?: React.ReactNode;
/** "actions" for buttons; "text" for plain context, indented to the face's text edge */
railKind?: "actions" | "text";
as?: "div" | "figure" | "article";
className?: string;
faceClassName?: string;
children: React.ReactNode;
}
/* Every card is built like every other object in the kit: a chassis (bezel)
holding a face. Content goes on the face; controls and context go on the
rail, the strip of chassis below it. The two are nearly the same tone —
the hairline around the face is what separates them.
Geometry, measured from the card's outer edge:
- chassis 24px radius, 6px bezel → face 18px radius (concentric)
- rail content sits 12px in from the left and bottom, 10px below the face
- a button's label then starts 30px in — the same edge as the face's text,
and plain-text rails are indented to that edge too */
export function CardShell({
theme = "light",
rail,
railKind = "actions",
as: Tag = "div",
className = "",
faceClassName = "",
children,
}: CardShellProps) {
const dark = theme === "dark";
return (
<Tag
className={[
"rim relative m-0 rounded-[24px] p-1.5 flex flex-col h-full",
rail ? "pb-0" : "",
className,
].join(" ")}
style={{
background: dark ? "var(--card-bezel-dark)" : "var(--card-bezel-light)",
boxShadow: dark ? "var(--card-dark-shadow)" : "var(--card-light-shadow)",
["--rim" as string]: dark ? "rgba(255,255,255,0.1)" : "rgba(255,255,255,1)",
} as React.CSSProperties}
>
<div
className={["relative flex-1 min-h-0 rounded-[18px] overflow-hidden flex flex-col", faceClassName].join(" ")}
style={{
background: dark ? "var(--card-dark)" : "var(--card-light)",
boxShadow: dark ? "var(--card-face-dark-shadow)" : "var(--card-face-light-shadow)",
}}
>
{children}
</div>
{rail && (
<div className="relative shrink-0 px-1.5 pt-2.5 pb-3">
<div
className={[
// 40px = a small button's full height, so text rails and button
// rails come out the same height and line up across a row
"min-h-10 flex items-center gap-2 text-[12.5px]",
railKind === "text" ? "pl-[18px] pr-3 gap-2.5" : "",
dark ? "text-neutral-400" : "text-neutral-500",
].join(" ")}
>
{rail}
</div>
</div>
)}
</Tag>
);
}

usage

tsx
import { Carousel, CarouselSlide } from "@blurrow/ui";
tsx
<Carousel autoPlay items={[<CarouselSlide key="a" title="…" />]} />

props

straight from the source, so they're always up to date.

propsts
type SlideTheme = "dark" | "light" | "tint";
interface CarouselProps {
items: React.ReactNode[];
autoPlay?: boolean;
interval?: number;
className?: string;
}
interface CarouselSlideProps {
theme?: SlideTheme;
/** Background for the "tint" theme */
tint?: string;
icon?: React.ReactNode;
title: React.ReactNode;
meta?: React.ReactNode;
}