Kindle
A cursor-following radial spotlight card — a soft white glow that breathes gently and brightens the nearest border edge as it passes. Pure CSS, works with arbitrary children.
cssfree
"use client";
import * as React from "react";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
import { cn } from "@/lib/utils";
export interface KindleProps extends React.ComponentPropsWithoutRef<"div"> {
/** Arbitrary card content. */
children: React.ReactNode;
/** Core color of the spotlight, any CSS color. @default soft white */
color?: string;
/** Outer rim / haze color, any CSS color. @default faint white */
rimColor?: string;
/** Spotlight radius in px. @default 240 */
radius?: number;
/** Brighten the border segment nearest the cursor. @default true */
borderGlow?: boolean;
}
/**
* Kindle — a cursor-following radial spotlight card. A hot white core rides
* inside a wider signature-hue halo and a faint conic sheen; the light tracks
* the pointer with an eased lag and the nearest border segment blooms under
* it. At rest the card is alive — a top-lit machined bevel, a slow breathing
* glow, and a sheen that drifts across the face — so it never reads as a dead
* slab. Low-opacity grain kills banding for a shader-grade surface.
* Pure CSS + a lightweight pointer listener; works with arbitrary children.
* Freezes to a designed, legible static glow under `prefers-reduced-motion`.
*/
export function Kindle({
children,
className,
style,
color = "rgba(255,255,255,0.55)",
rimColor = "rgba(255,255,255,0.3)",
radius = 240,
borderGlow = true,
...props
}: KindleProps) {
const reducedMotion = useReducedMotion();
const nodeRef = React.useRef<HTMLDivElement>(null);
const handlePointerMove = React.useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (reducedMotion) return;
const node = nodeRef.current;
if (!node) return;
const rect = node.getBoundingClientRect();
const x = ((event.clientX - rect.left) / rect.width) * 100;
const y = ((event.clientY - rect.top) / rect.height) * 100;
node.style.setProperty("--kindle-x", `${x.toFixed(2)}%`);
node.style.setProperty("--kindle-y", `${y.toFixed(2)}%`);
},
[reducedMotion]
);
const handlePointerEnter = React.useCallback(() => {
if (reducedMotion) return;
nodeRef.current?.setAttribute("data-hover", "true");
}, [reducedMotion]);
const handlePointerLeave = React.useCallback(() => {
nodeRef.current?.setAttribute("data-hover", "false");
}, []);
return (
<div
ref={nodeRef}
data-crucible="kindle"
data-hover="false"
data-reduced-motion={reducedMotion}
onPointerMove={handlePointerMove}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
className={cn(
"group relative overflow-hidden rounded-2xl border border-white/10 bg-neutral-900",
className
)}
style={{
["--kindle-color" as string]: color,
["--kindle-rim" as string]: rimColor,
["--kindle-radius" as string]: `${radius}px`,
["--kindle-haze-radius" as string]: `${radius * 1.5}px`,
["--kindle-x" as string]: "50%",
["--kindle-y" as string]: "30%",
...style,
}}
{...props}
>
<style>{`
@property --kindle-x {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --kindle-y {
syntax: "<percentage>";
inherits: true;
initial-value: 30%;
}
[data-crucible="kindle"] {
isolation: isolate;
/* Registered custom properties interpolate, so the glow trails the
pointer with a soft eased lag instead of snapping per-frame.
Unregistered fallback (older engines) just follows instantly. */
transition:
--kindle-x 240ms cubic-bezier(0.22, 1, 0.36, 1),
--kindle-y 240ms cubic-bezier(0.22, 1, 0.36, 1);
}
[data-crucible="kindle"] .kindle-grain,
[data-crucible="kindle"] .kindle-bevel,
[data-crucible="kindle"] .kindle-rest,
[data-crucible="kindle"] .kindle-sheen,
[data-crucible="kindle"] .kindle-core,
[data-crucible="kindle"] .kindle-haze,
[data-crucible="kindle"] .kindle-border {
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
}
/* Machined depth: top-lit inner bevel (bright top return, dark bottom),
a hairline inner ring, and a soft inner vignette that seats the
surface. Always on — this is the card's material, not an effect. */
[data-crucible="kindle"] .kindle-bevel {
box-shadow:
inset 0 1px 0 color-mix(in oklab, var(--kindle-rim) 55%, transparent),
inset 0 2px 10px -6px color-mix(in oklab, var(--kindle-rim) 60%, transparent),
inset 0 -1px 0 rgba(0, 0, 0, 0.55),
inset 0 -18px 34px -22px rgba(0, 0, 0, 0.7),
inset 0 0 0 1px rgba(255, 255, 255, 0.03);
background: radial-gradient(
135% 120% at 50% -20%,
transparent 55%,
rgba(0, 0, 0, 0.28) 100%
);
}
/* Fine grain over the surface (under the content) — kills gradient
banding and gives the light a shader-like, non-flat read. */
[data-crucible="kindle"] .kindle-grain {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='k'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23k)'/%3E%3C/svg%3E");
opacity: 0.05;
mix-blend-mode: overlay;
}
/* Idle breathing glow: a soft top-lit haze that swells and recedes so
the card has life at rest. Signature hue, slow. */
[data-crucible="kindle"] .kindle-rest {
opacity: 1;
transform-origin: 50% -10%;
background:
radial-gradient(
120% 80% at 50% -18%,
color-mix(in oklab, var(--kindle-rim) 34%, transparent),
color-mix(in oklab, var(--kindle-rim) 10%, transparent) 46%,
transparent 70%
),
radial-gradient(
70% 50% at 50% 118%,
color-mix(in oklab, var(--kindle-color) 12%, transparent),
transparent 72%
);
animation: kindle-breathe 6400ms ease-in-out infinite;
transition: opacity 400ms ease;
}
[data-crucible="kindle"][data-hover="true"] .kindle-rest {
opacity: 0.5;
}
/* Drifting sheen: a wide diagonal light bar that slides slowly across
the face — a second, independent thread of idle motion. */
[data-crucible="kindle"] .kindle-sheen {
opacity: 0.55;
background: linear-gradient(
114deg,
transparent 38%,
color-mix(in oklab, var(--kindle-rim) 16%, transparent) 50%,
transparent 62%
);
background-size: 230% 100%;
background-repeat: no-repeat;
background-position: -20% 0;
mix-blend-mode: screen;
animation: kindle-sheen-drift 12s ease-in-out infinite alternate;
transition: opacity 400ms ease;
}
[data-crucible="kindle"][data-hover="true"] .kindle-sheen {
opacity: 0.28;
}
/* Wide signature-hue halo with a faint conic sheen woven in — the
volumetric outer glow that the hot core rides inside of. */
[data-crucible="kindle"] .kindle-haze {
opacity: 0;
background:
conic-gradient(
from 130deg at var(--kindle-x) var(--kindle-y),
transparent,
color-mix(in oklab, var(--kindle-rim) 10%, transparent) 22%,
transparent 46%,
color-mix(in oklab, var(--kindle-rim) 8%, transparent) 72%,
transparent
),
radial-gradient(
var(--kindle-haze-radius) circle at var(--kindle-x) var(--kindle-y),
color-mix(in oklab, var(--kindle-rim) 34%, transparent),
color-mix(in oklab, var(--kindle-rim) 14%, transparent) 45%,
transparent 78%
);
mix-blend-mode: screen;
transform-origin: var(--kindle-x) var(--kindle-y);
animation: kindle-pulse 3200ms ease-in-out infinite;
transition: opacity 350ms ease;
}
/* Tight hot core: white-hot center melting into the signature hue —
the two-layer read is what makes the light feel volumetric. */
[data-crucible="kindle"] .kindle-core {
opacity: 0;
background: radial-gradient(
calc(var(--kindle-radius) * 0.58) circle at var(--kindle-x) var(--kindle-y),
color-mix(in oklab, var(--kindle-color) 72%, white),
color-mix(in oklab, var(--kindle-color) 52%, transparent) 30%,
color-mix(in oklab, var(--kindle-color) 22%, transparent) 58%,
transparent 76%
);
mix-blend-mode: screen;
transition: opacity 350ms ease;
}
/* Border bloom: a 1px gradient ring that brightens — and softly
blooms — on the edge segment nearest the cursor. */
[data-crucible="kindle"] .kindle-border {
opacity: 0;
padding: 1px;
background: radial-gradient(
calc(var(--kindle-radius) * 0.95) circle at var(--kindle-x) var(--kindle-y),
color-mix(in oklab, var(--kindle-color) 40%, white),
color-mix(in oklab, var(--kindle-color) 75%, transparent) 32%,
transparent 64%
);
filter: drop-shadow(0 0 5px color-mix(in oklab, var(--kindle-color) 45%, transparent));
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
transition: opacity 350ms ease;
}
[data-crucible="kindle"][data-hover="true"] .kindle-core,
[data-crucible="kindle"][data-hover="true"] .kindle-haze,
[data-crucible="kindle"][data-hover="true"] .kindle-border {
opacity: 1;
}
@keyframes kindle-breathe {
0%, 100% { opacity: 0.82; transform: scale(1); }
50% { opacity: 1; transform: scale(1.04); }
}
@keyframes kindle-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.06); }
}
@keyframes kindle-sheen-drift {
from { background-position: -25% 0; }
to { background-position: 125% 0; }
}
/* Reduced motion: a designed, legible static frame — machined bevel,
grain, a still resting glow, a fixed sheen, and a soft centered
spotlight pinned at the default position. Never blank, never frozen
mid-animation. */
[data-crucible="kindle"][data-reduced-motion="true"] {
transition: none;
}
[data-crucible="kindle"][data-reduced-motion="true"] .kindle-rest {
opacity: 1;
transform: scale(1);
animation: none;
transition: none;
}
[data-crucible="kindle"][data-reduced-motion="true"] .kindle-sheen {
opacity: 0.4;
background-position: 62% 0;
animation: none;
transition: none;
}
[data-crucible="kindle"][data-reduced-motion="true"] .kindle-core {
opacity: 0.42;
animation: none;
transition: none;
}
[data-crucible="kindle"][data-reduced-motion="true"] .kindle-haze {
opacity: 0.32;
transform: scale(1);
animation: none;
transition: none;
}
[data-crucible="kindle"][data-reduced-motion="true"] .kindle-border {
opacity: 0.5;
transition: none;
}
@media (prefers-reduced-motion: reduce) {
[data-crucible="kindle"] .kindle-rest,
[data-crucible="kindle"] .kindle-sheen,
[data-crucible="kindle"] .kindle-haze {
animation: none;
}
}
`}</style>
<div className="kindle-grain" aria-hidden />
<div className="kindle-bevel" aria-hidden />
<div className="kindle-rest" aria-hidden />
<div className="kindle-sheen" aria-hidden />
<div className="kindle-haze" aria-hidden />
<div className="kindle-core" aria-hidden />
{borderGlow && <div className="kindle-border" aria-hidden />}
<div className="relative z-10">{children}</div>
</div>
);
}
Installation
CLI
npx shadcn@latest add @crucible/kindleProps
| Prop | Type | Default | Description |
|---|---|---|---|
| children | React.ReactNode | Arbitrary card content. | |
| color | string | soft white | Core color of the spotlight, any CSS color. |
| rimColor | string | faint white | Outer rim / haze color, any CSS color. |
| radius | number | 240 | Spotlight radius in px. |
| borderGlow | boolean | true | Brighten the border segment nearest the cursor. |
Also accepts all props of React.ComponentPropsWithoutRef<"div"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, and passes className through.