Skip to content

Commit 33dceaa

Browse files
committed
Fix spotlight effect behavior
1 parent cde2877 commit 33dceaa

14 files changed

Lines changed: 483 additions & 9 deletions

File tree

README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Protohiro Effects
22

3-
Protohiro Effects is a zero-wrapper React library for hard CSS effects like gradient borders, glow rings, and noise overlays.
3+
Protohiro Effects is a zero-wrapper React library for hard CSS effects like gradient borders, glow rings, noise overlays, and spotlight overlays.
44

55
## Live demo
66

@@ -56,6 +56,24 @@ Options:
5656
- `intensity?: number`
5757
- `disabled?: boolean`
5858

59+
### `useSpotlightEffect(options)`
60+
61+
Options:
62+
- `mode?: 'glow' | 'reveal'`
63+
- `size?: string | number`
64+
- `intensity?: number`
65+
- `color?: string`
66+
- `softness?: number`
67+
- `coreIntensity?: number`
68+
- `x?: string | number`
69+
- `y?: string | number`
70+
- `followPointer?: boolean`
71+
- `revealColor?: string`
72+
- `revealImage?: string`
73+
- `revealSize?: string | number`
74+
- `revealOpacity?: number`
75+
- `disabled?: boolean`
76+
5977
## Safari notes
6078

6179
`gradient-border` uses `mask-composite` for the preferred ring rendering path. Safari fallback uses a simplified border and layered background. The fallback keeps content readable and does not hide element content.

apps/demo/src/App.tsx

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,23 @@
1-
import { useGlowEffect, useGradientBorderEffect, useNoiseEffect } from '@protohiro/effects';
1+
import {
2+
useGlowEffect,
3+
useGradientBorderEffect,
4+
useNoiseEffect,
5+
useSpotlightEffect,
6+
} from '@protohiro/effects';
27
import { useMemo, useState, type CSSProperties, type RefCallback } from 'react';
8+
import revealImageAsset from './assets/reveal.jpeg';
39

410
type DemoOption = string | number | boolean;
511
type DemoOptions = Record<string, DemoOption>;
6-
type ControlType = 'text' | 'number' | 'range' | 'checkbox' | 'color';
12+
type ControlType = 'text' | 'number' | 'range' | 'checkbox' | 'color' | 'select';
713
type PreviewElement = 'button' | 'div' | 'article';
814
type EffectHook = (options: DemoOptions) => RefCallback<HTMLElement>;
915

1016
type DemoControl = {
1117
key: string;
1218
label: string;
1319
type: ControlType;
20+
selectOptions?: string[];
1421
min?: number;
1522
max?: number;
1623
step?: number;
@@ -42,6 +49,10 @@ function useNoiseStoryHook(options: DemoOptions): RefCallback<HTMLElement> {
4249
return useNoiseEffect(options);
4350
}
4451

52+
function useSpotlightStoryHook(options: DemoOptions): RefCallback<HTMLElement> {
53+
return useSpotlightEffect(options);
54+
}
55+
4556
const STORIES: EffectStory[] = [
4657
{
4758
id: 'gradient-border',
@@ -116,6 +127,52 @@ const STORIES: EffectStory[] = [
116127
{ key: 'disabled', label: 'Disabled', type: 'checkbox' },
117128
],
118129
},
130+
{
131+
id: 'spotlight',
132+
title: 'Spotlight',
133+
description: 'Interactive light cone with cinematic falloff and pointer tracking.',
134+
hookName: 'useSpotlightEffect',
135+
componentName: 'SpotlightCard',
136+
previewText: 'Move pointer across the card',
137+
previewElement: 'article',
138+
previewStyle: {
139+
background:
140+
'radial-gradient(circle at 8% 12%, rgba(99, 102, 241, 0.26), transparent 34%), radial-gradient(circle at 90% 82%, rgba(45, 212, 191, 0.12), transparent 42%), linear-gradient(150deg, #090617 0%, #0a1023 48%, #070b1a 100%)',
141+
color: '#f5f3ff',
142+
border: '1px solid rgba(129, 140, 248, 0.32)',
143+
boxShadow:
144+
'inset 0 1px 0 rgba(255, 255, 255, 0.1), inset 0 -24px 45px rgba(12, 8, 35, 0.42), 0 24px 70px rgba(3, 6, 19, 0.58)',
145+
},
146+
hook: useSpotlightStoryHook,
147+
defaults: {
148+
mode: 'reveal',
149+
color: '#2a2079',
150+
size: 50,
151+
intensity: 0.3,
152+
softness: 0.9,
153+
coreIntensity: 0.35,
154+
x: 36,
155+
y: 32,
156+
followPointer: true,
157+
revealColor: '#38bdf8',
158+
revealImage: revealImageAsset,
159+
revealOpacity: 0.9,
160+
},
161+
controls: [
162+
{ key: 'mode', label: 'Mode', type: 'select', selectOptions: ['reveal', 'glow'] },
163+
{ key: 'color', label: 'Color', type: 'color' },
164+
{ key: 'size', label: 'Size', type: 'number', min: 0, max: 280, step: 1 },
165+
{ key: 'intensity', label: 'Intensity', type: 'range', min: 0, max: 1, step: 0.01 },
166+
{ key: 'softness', label: 'Softness', type: 'range', min: 0, max: 1, step: 0.01 },
167+
{ key: 'coreIntensity', label: 'Core intensity', type: 'range', min: 0, max: 1, step: 0.01 },
168+
{ key: 'revealColor', label: 'Reveal color', type: 'color' },
169+
{ key: 'revealOpacity', label: 'Reveal opacity', type: 'range', min: 0, max: 1, step: 0.01 },
170+
{ key: 'x', label: 'X', type: 'range', min: 0, max: 100, step: 1 },
171+
{ key: 'y', label: 'Y', type: 'range', min: 0, max: 100, step: 1 },
172+
{ key: 'followPointer', label: 'Follow pointer', type: 'checkbox' },
173+
{ key: 'disabled', label: 'Disabled', type: 'checkbox' },
174+
],
175+
},
119176
];
120177

121178
function formatOptionValue(value: DemoOption): string {
@@ -150,6 +207,7 @@ function EffectPlayground({ story }: { story: EffectStory }) {
150207
const useEffectHook = story.hook;
151208
const ref = useEffectHook(options);
152209
const PreviewTag = story.previewElement;
210+
const previewClassName = story.id === 'spotlight' ? 'demo-card demo-card-spotlight' : 'demo-card';
153211
const hookSnippet = useMemo(() => createHookSnippet(story, options), [options, story]);
154212

155213
const controls = useMemo(
@@ -177,6 +235,30 @@ function EffectPlayground({ story }: { story: EffectStory }) {
177235
);
178236
}
179237

238+
if (control.type === 'select') {
239+
return (
240+
<label key={control.key} className="demo-control" htmlFor={inputId}>
241+
<span>{control.label}</span>
242+
<select
243+
id={inputId}
244+
value={String(value ?? '')}
245+
onChange={(event) => {
246+
setOptions((current) => ({
247+
...current,
248+
[control.key]: event.target.value,
249+
}));
250+
}}
251+
>
252+
{(control.selectOptions ?? []).map((optionValue) => (
253+
<option key={optionValue} value={optionValue}>
254+
{optionValue}
255+
</option>
256+
))}
257+
</select>
258+
</label>
259+
);
260+
}
261+
180262
return (
181263
<label key={control.key} className="demo-control" htmlFor={inputId}>
182264
<span>{control.label}</span>
@@ -216,7 +298,7 @@ function EffectPlayground({ story }: { story: EffectStory }) {
216298
<p>{story.description}</p>
217299
</header>
218300
<div className="demo-preview">
219-
<PreviewTag ref={ref} className="demo-card" style={story.previewStyle}>
301+
<PreviewTag ref={ref} className={previewClassName} style={story.previewStyle}>
220302
{story.previewText}
221303
</PreviewTag>
222304
</div>

apps/demo/src/assets/reveal.jpeg

2.44 MB
Loading

apps/demo/src/styles.css

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,18 @@ h1 {
147147
font-weight: 600;
148148
}
149149

150+
.demo-card-spotlight {
151+
min-height: 136px;
152+
display: flex;
153+
align-items: flex-start;
154+
justify-content: flex-start;
155+
padding: 1.05rem 1.2rem;
156+
font-size: 1rem;
157+
font-weight: 700;
158+
letter-spacing: 0.015em;
159+
text-shadow: 0 3px 16px rgba(3, 6, 23, 0.85);
160+
}
161+
150162
.demo-controls-pane {
151163
display: grid;
152164
align-content: start;
@@ -176,6 +188,15 @@ h1 {
176188
padding: 0.45rem 0.6rem;
177189
}
178190

191+
.demo-control select {
192+
width: 100%;
193+
border-radius: 10px;
194+
border: 1px solid rgba(148, 163, 184, 0.28);
195+
background: rgba(10, 21, 42, 0.95);
196+
color: #f8fafc;
197+
padding: 0.45rem 0.6rem;
198+
}
199+
179200
.demo-control input[type='color'] {
180201
min-height: 2.2rem;
181202
padding: 0.2rem 0.3rem;
@@ -186,6 +207,11 @@ h1 {
186207
outline-offset: 1px;
187208
}
188209

210+
.demo-control select:focus-visible {
211+
outline: 2px solid rgba(56, 189, 248, 0.6);
212+
outline-offset: 1px;
213+
}
214+
189215
.demo-control-toggle {
190216
display: flex;
191217
align-items: center;

apps/demo/src/vite-env.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/// <reference types="vite/client" />

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "protohiro-effects-monorepo",
33
"private": true,
4-
"version": "0.1.2",
4+
"version": "0.1.3",
55
"description": "Zero-wrapper React CSS effects engine.",
66
"packageManager": "pnpm@9.15.0",
77
"scripts": {

packages/core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@protohiro/effects-core",
3-
"version": "0.1.2",
3+
"version": "0.1.3",
44
"description": "Core runtime utilities for ProtoEffects CSS-first effects.",
55
"license": "MIT",
66
"type": "module",

packages/react/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export function Button() {
2424
- `useGradientBorderEffect`
2525
- `useGlowEffect`
2626
- `useNoiseEffect`
27+
- `useSpotlightEffect`
2728

2829
Live demo:
2930
https://libs.protohiro.com/effects/

packages/react/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@protohiro/effects",
3-
"version": "0.1.2",
3+
"version": "0.1.3",
44
"description": "Zero-wrapper React hooks for composable CSS effects.",
55
"license": "MIT",
66
"type": "module",

0 commit comments

Comments
 (0)