Kindle
A cursor-following radial spotlight card — an amber-cored, ember-rimmed glow that breathes gently and heats the nearest border edge as if the frame were being torched. 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;
/** Hot core color of the spotlight, any CSS color. @default molten amber */
color?: string;
/** Outer rim / heat-haze color, any CSS color. @default ember red */
rimColor?: string;
/** Spotlight radius in px. @default 240 */
radius?: number;
/** Heat the border segment nearest the cursor. @default true */
borderGlow?: boolean;
}
/**
* Kindle — a cursor-following radial spotlight card. A bed-of-coals glow
* tracks the pointer with a slow "breathing" heat-haze ring, and the nearest
* border segment heats to molten amber as if the frame were being torched.
* Pure CSS + a lightweight pointer listener; works with arbitrary children.
* Freezes to a static, low-opacity glow under `prefers-reduced-motion`.
*/
export function Kindle({
children,
className,
style,
color = "#ff6b35",
rimColor = "#c1121f",
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>{`
[data-crucible="kindle"] {
isolation: isolate;
}
[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;
opacity: 0;
transition: opacity 300ms ease;
}
[data-crucible="kindle"] .kindle-core {
background: radial-gradient(
var(--kindle-radius) circle at var(--kindle-x) var(--kindle-y),
color-mix(in oklab, var(--kindle-color) 70%, transparent),
transparent 70%
);
}
[data-crucible="kindle"] .kindle-haze {
background: radial-gradient(
var(--kindle-haze-radius) circle at var(--kindle-x) var(--kindle-y),
color-mix(in oklab, var(--kindle-rim) 28%, transparent),
transparent 75%
);
transform-origin: var(--kindle-x) var(--kindle-y);
animation: kindle-breathe 1s ease-in-out infinite;
}
[data-crucible="kindle"] .kindle-border {
padding: 1px;
background: radial-gradient(
var(--kindle-radius) circle at var(--kindle-x) var(--kindle-y),
var(--kindle-color),
transparent 70%
);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
}
[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;
}
[data-crucible="kindle"][data-reduced-motion="true"] .kindle-core,
[data-crucible="kindle"][data-reduced-motion="true"] .kindle-haze,
[data-crucible="kindle"][data-reduced-motion="true"] .kindle-border {
opacity: 0.3;
transition: none;
animation: none;
}
@keyframes kindle-breathe {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
@media (prefers-reduced-motion: reduce) {
[data-crucible="kindle"] .kindle-haze {
animation: none;
}
}
`}</style>
<div className="relative z-10">{children}</div>
<div className="kindle-core" aria-hidden />
<div className="kindle-haze" aria-hidden />
{borderGlow && <div className="kindle-border" aria-hidden />}
</div>
);
}
Installation
CLI
npx shadcn@latest add @crucible/kindleHonors prefers-reduced-motion with a designed static fallback, and passes className through.