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 carouselor 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
1"use client";2
3import React, { useState, useEffect, useRef } from "react";4import { Button } from "./Button";5import { CardShell } from "./CardShell";6
7interface CarouselProps {8 items: React.ReactNode[];9 autoPlay?: boolean;10 interval?: number;11 className?: string;12}13
14const SLIDE_MS = 680;15
16interface Slot {17 itemIndex: number;18 key: string;19 role: "current" | "prev";20}21
22export function Carousel({23 items,24 autoPlay = false,25 interval = 4000,26 className = "",27}: CarouselProps) {28 const [slots, setSlots] = useState<Slot[]>([29 { itemIndex: 0, key: "s-0-0", role: "current" },30 ]);31 const [direction, setDirection] = useState<"left" | "right">("right");32 const [currentIdx, setCurrentIdx] = useState(0);33 // Autoplay never moves content out from under someone looking at it34 const [paused, setPaused] = useState(false);35
36 const currentIdxRef = useRef(0);37 const lockedRef = useRef(false);38 const tcRef = useRef(0);39 const cleanupRef = useRef<ReturnType<typeof setTimeout> | null>(null);40
41 const go = (nextIdx: number, dir: "left" | "right") => {42 if (lockedRef.current || nextIdx === currentIdxRef.current) return;43
44 const prevIdx = currentIdxRef.current;45 const tc = ++tcRef.current;46
47 lockedRef.current = true;48 currentIdxRef.current = nextIdx;49
50 setDirection(dir);51 setCurrentIdx(nextIdx);52 setSlots([53 { itemIndex: nextIdx, key: `c-${nextIdx}-${tc}`, role: "current" },54 { itemIndex: prevIdx, key: `p-${prevIdx}-${tc}`, role: "prev" },55 ]);56
57 if (cleanupRef.current) clearTimeout(cleanupRef.current);58 cleanupRef.current = setTimeout(() => {59 setSlots([{ itemIndex: nextIdx, key: `c-${nextIdx}-${tc}`, role: "current" }]);60 lockedRef.current = false;61 }, SLIDE_MS + 20);62 };63
64 const goNext = () => go((currentIdxRef.current + 1) % items.length, "right");65 const goPrev = () => go((currentIdxRef.current - 1 + items.length) % items.length, "left");66
67 const goNextRef = useRef(goNext);68 goNextRef.current = goNext;69
70 useEffect(() => {71 if (!autoPlay || paused) return;72 if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;73 const id = setInterval(() => goNextRef.current(), interval);74 return () => clearInterval(id);75 }, [autoPlay, interval, paused]);76
77 const onKeyDown = (e: React.KeyboardEvent) => {78 if (e.key === "ArrowRight") { e.preventDefault(); goNextRef.current(); }79 if (e.key === "ArrowLeft") { e.preventDefault(); goPrev(); }80 };81
82 useEffect(() => () => { if (cleanupRef.current) clearTimeout(cleanupRef.current); }, []);83
84 // ── Swipe / pointer drag ──────────────────────────────────────────85 const dragRef = useRef<{ x: number; y: number; active: boolean }>({ x: 0, y: 0, active: false });86 const THRESHOLD = 48;87
88 const onPointerDown = (e: React.PointerEvent) => {89 dragRef.current = { x: e.clientX, y: e.clientY, active: true };90 (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);91 };92
93 const onPointerUp = (e: React.PointerEvent) => {94 if (!dragRef.current.active) return;95 const dx = e.clientX - dragRef.current.x;96 const dy = e.clientY - dragRef.current.y;97 dragRef.current.active = false;98 if (Math.abs(dx) > THRESHOLD && Math.abs(dx) > Math.abs(dy) * 1.5) {99 dx < 0 ? goNextRef.current() : goPrev();100 }101 };102
103 const onPointerCancel = () => { dragRef.current.active = false; };104
105 /* A plain slide: the incoming and outgoing slides travel together as106 one strip, a full width each. The curve is a strong ease-out, so the107 strip moves the instant you click and then settles — an ease-in-out108 hesitates first, which reads as lag. Driven by the Web Animations API109 on mount, so it never depends on a stylesheet. */110 const slide = (role: Slot["role"]) => (el: HTMLDivElement | null) => {111 if (!el || el.dataset.animated || slots.length < 2) return;112 el.dataset.animated = "1";113 if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;114 const sign = direction === "right" ? 1 : -1;115 const frames =116 role === "current"117 ? [{ transform: `translateX(${sign * 100}%)` }, { transform: "translateX(0)" }]118 : [{ transform: "translateX(0)" }, { transform: `translateX(${-sign * 100}%)` }];119 el.animate(frames, { duration: SLIDE_MS, easing: "cubic-bezier(0.22, 1, 0.36, 1)", fill: "both" });120 };121
122 const controls = (123 <div className="w-full flex items-center justify-between">124 <div className="flex items-center gap-3 pl-1.5">125 {/* Dots sit in a well; the active one fills over the autoplay126 interval, and the fill holds while autoplay is paused */}127 <div128 className="flex items-center gap-1 rounded-full p-[5px]"129 style={{ background: "var(--well)", boxShadow: "var(--well-shadow)" }}130 >131 {items.map((_, i) => {132 const active = i === currentIdx;133 return (134 <button135 key={i}136 onClick={() => go(i, i > currentIdxRef.current ? "right" : "left")}137 className={[138 "relative h-1.5 rounded-full overflow-hidden cursor-pointer",139 "transition-[width,background] duration-300 ease-[var(--ease-out-soft)]",140 "after:absolute after:-inset-2 after:content-['']",141 active ? "w-6 bg-neutral-900/15" : "w-1.5 bg-neutral-900/20 hover:bg-neutral-900/40",142 ].join(" ")}143 aria-label={`Go to slide ${i + 1}`}144 aria-current={active ? "true" : undefined}145 >146 {active && (147 <span148 key={`${currentIdx}-${autoPlay}`}149 aria-hidden150 className={["absolute inset-0 rounded-full origin-left", autoPlay ? "carousel-progress" : ""].join(" ")}151 style={{152 background: "var(--face-dark)",153 animationDuration: `${interval}ms`,154 animationPlayState: paused ? "paused" : "running",155 }}156 />157 )}158 </button>159 );160 })}161 </div>162 <span className="text-[12px] font-medium text-neutral-600 tabular-nums">163 {currentIdx + 1}164 <span className="text-neutral-400"> / {items.length}</span>165 </span>166 </div>167
168 <div className="flex gap-2">169 <Button variant="secondary" size="sm" square onClick={goPrev} aria-label="Previous slide">170 <svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">171 <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />172 </svg>173 </Button>174 <Button variant="secondary" size="sm" square onClick={goNext} aria-label="Next slide">175 <svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">176 <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />177 </svg>178 </Button>179 </div>180 </div>181 );182
183 /* Built on the card chassis like every other surface: slides are the184 face, controls live on the rail below it. */185 return (186 <section187 aria-roledescription="carousel"188 aria-label="Carousel"189 onKeyDown={onKeyDown}190 onMouseEnter={() => setPaused(true)}191 onMouseLeave={() => setPaused(false)}192 onFocus={() => setPaused(true)}193 onBlur={(e) => {194 if (!e.currentTarget.contains(e.relatedTarget as Node)) setPaused(false);195 }}196 className={className}197 >198 <CardShell rail={controls}>199 <div200 className="relative w-full overflow-hidden cursor-grab active:cursor-grabbing touch-pan-y"201 style={{ minHeight: 300 }}202 aria-live={autoPlay && !paused ? "off" : "polite"}203 onPointerDown={onPointerDown}204 onPointerUp={onPointerUp}205 onPointerCancel={onPointerCancel}206 >207 {slots.map((slot) => (208 <div209 key={slot.key}210 role="group"211 aria-roledescription="slide"212 aria-label={`${slot.itemIndex + 1} of ${items.length}`}213 aria-hidden={slot.role === "prev"}214 ref={slide(slot.role)}215 className={["absolute inset-0 will-change-transform [backface-visibility:hidden]", slot.role === "prev" ? "pointer-events-none" : ""].join(" ")}216 >217 {items[slot.itemIndex]}218 </div>219 ))}220 </div>221 </CardShell>222 </section>223 );224}225
226type SlideTheme = "dark" | "light" | "tint";227
228interface CarouselSlideProps {229 theme?: SlideTheme;230 /** Background for the "tint" theme */231 tint?: string;232 icon?: React.ReactNode;233 title: React.ReactNode;234 meta?: React.ReactNode;235}236
237/* A ready-made slide in the cards' vocabulary: the card's own material,238 a raised icon tile, and the same fading dot grid as empty media. */239export function CarouselSlide({ theme = "dark", tint = "#8A8E7A", icon, title, meta }: CarouselSlideProps) {240 const dark = theme !== "light";241 return (242 <div243 className="relative w-full h-full flex flex-col justify-end gap-6 p-8 overflow-hidden select-none"244 style={{245 background: theme === "dark" ? "var(--card-dark)" : theme === "light" ? "var(--card-light)" : tint,246 }}247 >248 <div249 aria-hidden250 className="absolute inset-y-0 right-0 w-3/5"251 style={{252 backgroundImage: `radial-gradient(${253 theme === "tint" ? "rgba(255,255,255,0.22)" : dark ? "rgba(255,255,255,0.09)" : "rgba(17,17,17,0.08)"254 } 1px, transparent 1px)`,255 backgroundSize: "12px 12px",256 maskImage: "radial-gradient(60% 70% at 70% 50%, #000 0%, transparent 100%)",257 WebkitMaskImage: "radial-gradient(60% 70% at 70% 50%, #000 0%, transparent 100%)",258 }}259 />260 {icon && (261 <div262 aria-hidden263 className={[264 "relative w-9 h-9 rounded-[11px] flex items-center justify-center mb-auto",265 dark ? "text-neutral-100" : "text-neutral-700",266 ].join(" ")}267 style={{268 background: dark ? "var(--tile-dark)" : "var(--face-light)",269 boxShadow: dark ? "var(--tile-dark-shadow)" : "var(--tile-light-shadow)",270 }}271 >272 {icon}273 </div>274 )}275 <div className="relative">276 <p277 className={[278 "text-[22px] font-medium leading-[1.2] tracking-[-0.015em] mb-2",279 dark ? "text-white" : "text-neutral-900",280 ].join(" ")}281 >282 {title}283 </p>284 {meta && (285 <p className={["text-[13px]", theme === "tint" ? "text-white/65" : dark ? "text-neutral-400" : "text-neutral-500"].join(" ")}>286 {meta}287 </p>288 )}289 </div>290 </div>291 );292}components/ui/Button.tsxtsx
1"use client";2
3import React, { useState, useRef, useCallback } from "react";4
5type ButtonVariant = "primary" | "secondary" | "ghost" | "outline";6type ButtonSize = "sm" | "md" | "lg" | "icon";7
8interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {9 variant?: ButtonVariant;10 size?: ButtonSize;11 children: React.ReactNode;12 fullWidth?: boolean;13 /** Surface the button sits on. On dark cards, secondary/outline switch to a14 dark face so they don't glare against the card. */15 tone?: "light" | "dark";16 /** Equal width and height, for icon-only layered buttons. */17 square?: boolean;18}19
20/* Layered buttons are a raised face seated in a machined bezel. The bezel21 never moves; the face lifts on hover and sinks flush when pressed. */22const shellConfig: Record<23 string,24 {25 bezel: string;26 bezelShadow: string;27 face: string;28 faceShadow: string;29 faceHoverShadow: string;30 facePressedShadow: string;31 text: string;32 textShadow: string;33 fill: string;34 }35> = {36 primary: {37 bezel: "var(--bezel-dark)",38 bezelShadow: "var(--bezel-dark-shadow)",39 face: "var(--face-dark)",40 faceShadow: "var(--face-dark-shadow)",41 faceHoverShadow:42 "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)",43 facePressedShadow: "var(--face-dark-pressed)",44 text: "text-white",45 textShadow: "0 -1px 0 rgba(0,0,0,0.45)",46 fill: "bg-white/[0.05]",47 },48 secondary: {49 bezel: "var(--bezel-light)",50 bezelShadow: "var(--bezel-light-shadow)",51 face: "var(--face-light)",52 faceShadow: "var(--face-light-shadow)",53 faceHoverShadow:54 "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)",55 facePressedShadow: "var(--face-light-pressed)",56 text: "text-neutral-800",57 textShadow: "0 1px 0 rgba(255,255,255,0.9)",58 fill: "bg-neutral-900/[0.025]",59 },60 outline: {61 bezel: "linear-gradient(180deg, rgba(255,255,255,0.5) 0%, rgba(17,17,17,0.04) 100%)",62 bezelShadow:63 "0 0 0 0.5px rgba(17,17,17,0.048), inset 0 2px 3px -2px rgba(255,255,255,0.45)",64 face: "var(--face-light)",65 faceShadow:66 "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)",67 faceHoverShadow:68 "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)",69 facePressedShadow: "var(--face-light-pressed)",70 text: "text-neutral-700",71 textShadow: "0 1px 0 rgba(255,255,255,0.9)",72 fill: "bg-neutral-900/[0.025]",73 },74};75
76/* Secondary on a dark surface: a raised face a few steps lighter than the77 card — present, but in the same family of tones. */78const darkSecondary = {79 bezel: "linear-gradient(180deg, #2a2a2a 0%, #161616 100%)",80 bezelShadow:81 "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)",82 face: "linear-gradient(180deg, #353535 0%, #2a2a2a 55%, #242424 100%)",83 faceShadow:84 "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)",85 faceHoverShadow:86 "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)",87 facePressedShadow: "var(--face-dark-pressed)",88 text: "text-neutral-100",89 textShadow: "0 -1px 0 rgba(0,0,0,0.4)",90 fill: "bg-white/[0.04]",91};92
93/* Flat buttons (ghost, icon) are a single face without a bezel. */94const flatConfig: Record<95 ButtonVariant,96 {97 bg: string;98 background?: string;99 fill: string;100 shadow: string;101 hoverShadow: string;102 pressedShadow: string;103 }104> = {105 primary: {106 bg: "text-white",107 background: "var(--face-dark)",108 fill: "bg-white/[0.08]",109 shadow: "var(--face-dark-shadow)",110 hoverShadow:111 "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)",112 pressedShadow: "var(--face-dark-pressed)",113 },114 secondary: {115 bg: "text-neutral-800",116 background: "var(--face-light)",117 fill: "bg-neutral-900/[0.035]",118 shadow: "var(--face-light-shadow)",119 hoverShadow:120 "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)",121 pressedShadow: "var(--face-light-pressed)",122 },123 ghost: {124 bg: "bg-transparent text-neutral-500 hover:text-neutral-800",125 fill: "bg-neutral-900/[0.05]",126 shadow: "none",127 hoverShadow: "inset 0 0 2px rgba(17,17,17,0.09)",128 pressedShadow: "inset 0 1px 2px rgba(17,17,17,0.08)",129 },130 outline: {131 bg: "bg-transparent text-neutral-700",132 fill: "bg-neutral-900/[0.035]",133 shadow: "inset 0 0 2px rgba(17,17,17,0.15)",134 hoverShadow:135 "inset 0 0 2px rgba(17,17,17,0.24), 0 1px 2px rgba(17,17,17,0.05)",136 pressedShadow:137 "inset 0 0 2px rgba(17,17,17,0.24), inset 0 1px 2px rgba(17,17,17,0.06)",138 },139};140
141/* Bezel radius minus bezel padding = face radius, so the curves stay concentric. */142const shellRadius: Record<ButtonSize, string> = {143 sm: "rounded-[12px] p-[3px]",144 md: "rounded-[13px] p-[3px]",145 lg: "rounded-[15px] p-[3.5px]",146 icon: "",147};148
149const faceRadius: Record<ButtonSize, string> = {150 sm: "rounded-[9px]",151 md: "rounded-[10px]",152 lg: "rounded-[11.5px]",153 icon: "",154};155
156const innerPadding: Record<ButtonSize, string> = {157 sm: "px-4 py-2 text-[13px]",158 md: "px-5 py-2.5 text-sm",159 lg: "px-7 py-3 text-[15px]",160 icon: "w-7 h-7 p-0 text-[13px]",161};162
163const squarePadding: Record<ButtonSize, string> = {164 sm: "w-9 h-9 p-0 text-[13px]",165 md: "w-10 h-10 p-0 text-sm",166 lg: "w-12 h-12 p-0 text-[15px]",167 icon: "",168};169
170const flatSizeStyles: Record<ButtonSize, string> = {171 sm: "px-4 py-2.5 text-[13px] rounded-[10px]",172 md: "px-5 py-3 text-sm rounded-[10px]",173 lg: "px-7 py-3.5 text-[15px] rounded-xl",174 icon: "w-8 h-8 p-0 rounded-[10px] text-[13px]",175};176
177export function Button({178 variant = "primary",179 size = "md",180 fullWidth = false,181 tone = "light",182 square = false,183 className = "",184 children,185 disabled,186 onClick,187 onPointerDown,188 onPointerUp,189 onPointerLeave,190 onPointerCancel,191 onPointerEnter,192 style,193 ...rest194}: ButtonProps) {195 const [pressed, setPressed] = useState(false);196 const [hovered, setHovered] = useState(false);197 const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);198
199 /* The press state is the button's own; handlers passed in run alongside200 it rather than replacing it */201 const handlePointerDown = useCallback(202 (e: React.PointerEvent<HTMLButtonElement>) => {203 if (timerRef.current) clearTimeout(timerRef.current);204 setPressed(true);205 onPointerDown?.(e);206 },207 [onPointerDown],208 );209
210 const handlePointerUp = useCallback(211 (e: React.PointerEvent<HTMLButtonElement>) => {212 timerRef.current = setTimeout(() => setPressed(false), 150);213 onPointerUp?.(e);214 },215 [onPointerUp],216 );217
218 const handlePointerCancel = useCallback(219 (e: React.PointerEvent<HTMLButtonElement>) => {220 timerRef.current = setTimeout(() => setPressed(false), 150);221 onPointerCancel?.(e);222 },223 [onPointerCancel],224 );225
226 const handlePointerLeave = useCallback(227 (e: React.PointerEvent<HTMLButtonElement>) => {228 setHovered(false);229 if (timerRef.current) clearTimeout(timerRef.current);230 timerRef.current = setTimeout(() => setPressed(false), 150);231 onPointerLeave?.(e);232 },233 [onPointerLeave],234 );235
236 const layered = variant !== "ghost" && size !== "icon";237
238 const handlers = {239 onClick,240 onPointerDown: handlePointerDown,241 onPointerUp: handlePointerUp,242 onPointerLeave: handlePointerLeave,243 onPointerCancel: handlePointerCancel,244 onPointerEnter: (e: React.PointerEvent<HTMLButtonElement>) => {245 setHovered(true);246 onPointerEnter?.(e);247 },248 };249
250 if (layered) {251 const cfg =252 tone === "dark" && (variant === "secondary" || variant === "outline")253 ? darkSecondary254 : shellConfig[variant];255 const faceShadow = pressed256 ? cfg.facePressedShadow257 : hovered258 ? cfg.faceHoverShadow259 : cfg.faceShadow;260
261 return (262 <button263 data-btn264 className={[265 "group/btn relative inline-flex cursor-pointer select-none whitespace-nowrap",266 shellRadius[size],267 "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900",268 "disabled:opacity-40 disabled:pointer-events-none",269 fullWidth ? "w-full" : "",270 className,271 ]272 .filter(Boolean)273 .join(" ")}274 style={{275 background: cfg.bezel,276 boxShadow: cfg.bezelShadow,277 ...style,278 }}279 disabled={disabled}280 {...handlers}281 {...rest}282 >283 <span284 className={[285 "btn-face relative overflow-hidden flex items-center justify-center gap-1.5 font-medium",286 faceRadius[size],287 cfg.text,288 square ? squarePadding[size] : ["w-full", innerPadding[size]].join(" "),289 ].join(" ")}290 style={{291 background: cfg.face,292 boxShadow: faceShadow,293 textShadow: cfg.textShadow,294 transform: pressed295 ? "translateY(0.5px) scale(0.985)"296 : hovered297 ? "translateY(-0.5px)"298 : "none",299 transition:300 "transform 240ms var(--ease-spring), box-shadow 200ms ease-out",301 }}302 >303 <span304 aria-hidden="true"305 className={[306 "pointer-events-none absolute inset-0 origin-bottom transition-transform duration-300 ease-out",307 pressed308 ? "scale-y-100"309 : "scale-y-0 group-hover/btn:scale-y-100",310 cfg.fill,311 ].join(" ")}312 />313 <span className="btn-icon-nudge relative z-10 inline-flex items-center gap-1.5">314 {children}315 </span>316 </span>317 </button>318 );319 }320
321 const cfg = flatConfig[variant];322 const currentShadow = pressed323 ? cfg.pressedShadow324 : hovered325 ? cfg.hoverShadow326 : cfg.shadow;327
328 return (329 <button330 data-btn331 className={[332 "btn-face group/btn relative overflow-hidden inline-flex items-center justify-center gap-1.5 font-medium cursor-pointer select-none whitespace-nowrap",333 "transition-colors duration-200 ease-out",334 "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900",335 "disabled:opacity-40 disabled:pointer-events-none",336 cfg.bg,337 flatSizeStyles[size],338 fullWidth ? "w-full" : "",339 className,340 ]341 .filter(Boolean)342 .join(" ")}343 style={{344 background: cfg.background,345 boxShadow: currentShadow,346 transform: pressed347 ? "translateY(0.5px) scale(0.97)"348 : hovered && cfg.background349 ? "translateY(-0.5px)"350 : "none",351 transition:352 "transform 240ms var(--ease-spring), box-shadow 200ms ease-out, color 200ms ease-out",353 ...style,354 }}355 disabled={disabled}356 {...handlers}357 {...rest}358 >359 <span360 aria-hidden="true"361 className={[362 "pointer-events-none absolute inset-0 origin-bottom transition-transform duration-300 ease-out",363 pressed ? "scale-y-100" : "scale-y-0 group-hover/btn:scale-y-100",364 cfg.fill,365 ].join(" ")}366 />367 <span className="relative z-10 inline-flex items-center gap-1.5">368 {children}369 </span>370 </button>371 );372}components/ui/CardShell.tsxtsx
1import React from "react";2
3type Theme = "light" | "dark";4
5interface CardShellProps {6 theme?: Theme;7 /** Controls and context: actions, attribution, deltas. Lives on the chassis. */8 rail?: React.ReactNode;9 /** "actions" for buttons; "text" for plain context, indented to the face's text edge */10 railKind?: "actions" | "text";11 as?: "div" | "figure" | "article";12 className?: string;13 faceClassName?: string;14 children: React.ReactNode;15}16
17/* Every card is built like every other object in the kit: a chassis (bezel)18 holding a face. Content goes on the face; controls and context go on the19 rail, the strip of chassis below it. The two are nearly the same tone —20 the hairline around the face is what separates them.21
22 Geometry, measured from the card's outer edge:23 - chassis 24px radius, 6px bezel → face 18px radius (concentric)24 - rail content sits 12px in from the left and bottom, 10px below the face25 - a button's label then starts 30px in — the same edge as the face's text,26 and plain-text rails are indented to that edge too */27export function CardShell({28 theme = "light",29 rail,30 railKind = "actions",31 as: Tag = "div",32 className = "",33 faceClassName = "",34 children,35}: CardShellProps) {36 const dark = theme === "dark";37
38 return (39 <Tag40 className={[41 "rim relative m-0 rounded-[24px] p-1.5 flex flex-col h-full",42 rail ? "pb-0" : "",43 className,44 ].join(" ")}45 style={{46 background: dark ? "var(--card-bezel-dark)" : "var(--card-bezel-light)",47 boxShadow: dark ? "var(--card-dark-shadow)" : "var(--card-light-shadow)",48 ["--rim" as string]: dark ? "rgba(255,255,255,0.1)" : "rgba(255,255,255,1)",49 } as React.CSSProperties}50 >51 <div52 className={["relative flex-1 min-h-0 rounded-[18px] overflow-hidden flex flex-col", faceClassName].join(" ")}53 style={{54 background: dark ? "var(--card-dark)" : "var(--card-light)",55 boxShadow: dark ? "var(--card-face-dark-shadow)" : "var(--card-face-light-shadow)",56 }}57 >58 {children}59 </div>60
61 {rail && (62 <div className="relative shrink-0 px-1.5 pt-2.5 pb-3">63 <div64 className={[65 // 40px = a small button's full height, so text rails and button66 // rails come out the same height and line up across a row67 "min-h-10 flex items-center gap-2 text-[12.5px]",68 railKind === "text" ? "pl-[18px] pr-3 gap-2.5" : "",69 dark ? "text-neutral-400" : "text-neutral-500",70 ].join(" ")}71 >72 {rail}73 </div>74 </div>75 )}76 </Tag>77 );78}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;}