@exegia/specular 0.1.2 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +219 -192
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,50 +1,61 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
-
<img src="assets/logo.svg" alt="" width="
|
|
3
|
+
<img src="assets/logo.svg" alt="Specular reflection mark" width="96" height="96">
|
|
4
4
|
|
|
5
5
|
# @exegia/specular
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
utilities, a `cn()` that keeps them alive, and a rim highlight that tracks the
|
|
9
|
-
pointer.**
|
|
7
|
+
Pointer-following rim highlights and inset bezel utilities for Tailwind v4 + React.
|
|
10
8
|
|
|
11
|
-
[
|
|
9
|
+
[npm](https://www.npmjs.com/package/@exegia/specular) · [live examples](https://emmanuel-defreitas.github.io/specular/) · [MIT](LICENSE)
|
|
12
10
|
|
|
13
|
-
<
|
|
11
|
+
<a href="https://emmanuel-defreitas.github.io/specular/">
|
|
12
|
+
<img src="assets/demo.gif" alt="Six avatars with rim highlights following the pointer" width="100%">
|
|
13
|
+
</a>
|
|
14
14
|
|
|
15
|
-
<sub>Six discs, one window listener.
|
|
15
|
+
<sub>Six discs, one window listener. Explore the interactive presentation on the <a href="https://emmanuel-defreitas.github.io/specular/">example site</a>.</sub>
|
|
16
16
|
|
|
17
17
|
</div>
|
|
18
18
|
|
|
19
19
|
---
|
|
20
20
|
|
|
21
|
-
Declare a surface, get a utility family that draws a two-layer inset "bezel"
|
|
22
|
-
one axis at a time, a tailwind-merge config that keeps those utilities alive
|
|
23
|
-
in `cn()`, and React primitives that light the surface with the pointer.
|
|
24
|
-
|
|
25
21
|
## Install
|
|
26
22
|
|
|
27
23
|
```bash
|
|
28
|
-
|
|
24
|
+
bun add @exegia/specular
|
|
29
25
|
```
|
|
30
26
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
27
|
+
All entry points below are included in this one package. The package declares Tailwind CSS **4.1–4.x** as a peer dependency and Node.js **22.18+** as its engine. Set up Tailwind in your app before using the generated utility classes.
|
|
28
|
+
|
|
29
|
+
Install these optional peers only for the features you use, if your app does not already have them:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# For @exegia/specular/react
|
|
33
|
+
bun add react@^19
|
|
37
34
|
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
# For @exegia/specular/merge
|
|
36
|
+
bun add tailwind-merge@^3
|
|
37
|
+
```
|
|
40
38
|
|
|
41
39
|
## Setup
|
|
42
40
|
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
Choose the effect you want; you do not need every entry point.
|
|
42
|
+
|
|
43
|
+
| I want to… | What to set up |
|
|
44
|
+
| --- | --- |
|
|
45
|
+
| Add inset highlights/shadows with classes such as `bezel-base` | Define surfaces and register the Tailwind plugin: steps 1–3 below. React is not required. |
|
|
46
|
+
| Merge Specular classes in a `cn()` helper | Also pass the same surfaces to `createCn`: step 4. |
|
|
47
|
+
| Put a rim overlay on an avatar or image | Use `<Rim>` from `/react`. No surface config or Specular plugin is required. See [React effects](#exegiaspecularreact--react-effects). |
|
|
48
|
+
| Make a rim follow the pointer | Combine `usePointerLight(ref)` with `<Rim angle={angle}>`. |
|
|
49
|
+
| Make an inset bezel follow the pointer | Combine the Tailwind setup with `usePointerLight` and `litVars`. |
|
|
50
|
+
|
|
51
|
+
### 1. Define your surfaces
|
|
52
|
+
|
|
53
|
+
A **surface** is a named collection of shadow layers. Its name becomes the utility prefix: `bezel` below creates `bezel-*` classes. This is your app's configuration; Specular does not ship a default surface preset.
|
|
54
|
+
|
|
55
|
+
Create these two files at your project root:
|
|
45
56
|
|
|
46
57
|
```ts
|
|
47
|
-
//
|
|
58
|
+
// specular.config.ts
|
|
48
59
|
import { defineSurfaces } from "@exegia/specular"
|
|
49
60
|
|
|
50
61
|
export const surfaces = defineSurfaces({
|
|
@@ -54,212 +65,228 @@ export const surfaces = defineSurfaces({
|
|
|
54
65
|
dim: { color: "#000", y: -1, blur: 2, alpha: 15, dark: { y: -8, alpha: 20 } },
|
|
55
66
|
},
|
|
56
67
|
},
|
|
57
|
-
card: {
|
|
58
|
-
layers: {
|
|
59
|
-
lit: { inset: true, color: "#fff", y: 1, alpha: 60 },
|
|
60
|
-
drop: { inset: false, color: "#000", y: 4, blur: 8, alpha: 15 },
|
|
61
|
-
},
|
|
62
|
-
},
|
|
63
68
|
})
|
|
64
69
|
```
|
|
65
70
|
|
|
66
|
-
```
|
|
67
|
-
//
|
|
71
|
+
```ts
|
|
72
|
+
// specular.plugin.ts
|
|
68
73
|
import { createPlugin } from "@exegia/specular/plugin"
|
|
69
|
-
import { surfaces } from "./
|
|
74
|
+
import { surfaces } from "./specular.config.ts"
|
|
75
|
+
|
|
70
76
|
export default createPlugin(surfaces)
|
|
71
77
|
```
|
|
72
78
|
|
|
79
|
+
`defineSurfaces` validates the names and preserves TypeScript types. It does not generate CSS. `createPlugin` turns that configuration into Tailwind utilities.
|
|
80
|
+
|
|
81
|
+
### 2. Register the plugin in your CSS
|
|
82
|
+
|
|
83
|
+
In your app's Tailwind stylesheet:
|
|
84
|
+
|
|
73
85
|
```css
|
|
86
|
+
/* src/app.css */
|
|
74
87
|
@import "tailwindcss";
|
|
75
|
-
@plugin "
|
|
88
|
+
@plugin "../specular.plugin.ts";
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The plugin path is relative to this CSS file. Make sure your app loads this stylesheet.
|
|
92
|
+
|
|
93
|
+
**Register your local plugin file, not `@plugin "@exegia/specular/plugin"`.** The package exports a factory that needs your `surfaces`; the local file calls it and exports the configured plugin. There is no additional Specular stylesheet to import.
|
|
94
|
+
|
|
95
|
+
### 3. Use the generated classes
|
|
96
|
+
|
|
97
|
+
```tsx
|
|
98
|
+
<button className="rounded-lg bg-stone-200 px-4 py-2 bezel-base active:bezel-lit/20 active:bezel-dim/40">
|
|
99
|
+
Press me
|
|
100
|
+
</button>
|
|
76
101
|
```
|
|
77
102
|
|
|
103
|
+
`bezel-base` applies all configured layers. Utilities let you change individual properties:
|
|
104
|
+
|
|
105
|
+
| Class | Effect |
|
|
106
|
+
| --- | --- |
|
|
107
|
+
| `bezel-lit/60` | Set the `lit` layer's alpha to 60%. |
|
|
108
|
+
| `bezel-lit-t-2` | Light the top edge with a 2px inset offset. |
|
|
109
|
+
| `bezel-dim-b-1` | Set a 1px offset from the bottom edge. |
|
|
110
|
+
| `bezel-lit-blur-4` | Set the `lit` layer's blur to 4px. |
|
|
111
|
+
| `bezel-spread-1` | Set every layer's spread to 1px. |
|
|
112
|
+
|
|
113
|
+
Each modifier utility also applies the surface's full shadow, so `bezel-base` is useful for the preset but is not required alongside a modifier. Bare layer classes such as `bezel-lit` keep the configured alpha.
|
|
114
|
+
|
|
115
|
+
The `dark` values activate under a `.dark` ancestor by default, for example `<html class="dark">`. Your app controls that class. Change a surface's `darkSelector` to use another selector or `@media (prefers-color-scheme: dark)`.
|
|
116
|
+
|
|
117
|
+
### 4. Optional: configure `cn()`
|
|
118
|
+
|
|
119
|
+
Use this if you merge dynamic classes with tailwind-merge. Static `className` strings do not need it.
|
|
120
|
+
|
|
78
121
|
```ts
|
|
79
|
-
// lib/
|
|
122
|
+
// src/lib/cn.ts
|
|
80
123
|
import { createCn } from "@exegia/specular/merge"
|
|
81
|
-
import { surfaces } from "
|
|
124
|
+
import { surfaces } from "../../specular.config.ts"
|
|
125
|
+
|
|
82
126
|
export const cn = createCn(surfaces)
|
|
83
127
|
```
|
|
84
128
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
129
|
+
```tsx
|
|
130
|
+
<button className={cn("bezel-base bezel-lit/60", pressed && "bezel-lit/20")}>
|
|
131
|
+
Press me
|
|
132
|
+
</button>
|
|
133
|
+
```
|
|
89
134
|
|
|
90
|
-
|
|
135
|
+
**`createCn(surfaces)` requires your surface config.** There is no zero-argument default or separate options object. Create the helper once, using the same config as the plugin, then call `cn(...)` throughout your app. It merges normal Tailwind classes as well as Specular classes.
|
|
91
136
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
137
|
+
## Exports, by purpose
|
|
138
|
+
|
|
139
|
+
### `@exegia/specular` — shared configuration and inline styles
|
|
140
|
+
|
|
141
|
+
| Export | What it provides |
|
|
142
|
+
| --- | --- |
|
|
143
|
+
| `defineSurfaces(config)` | Validates your surface/layer names and returns the typed config shared by the plugin and merge helper. |
|
|
144
|
+
| `litVars(angle, depth?, surface?)` | Returns inline CSS variables that turn a surface's `lit` layer toward an angle. Defaults: depth `2.5` pixels, surface name `"card"`. |
|
|
145
|
+
| `Surfaces`, `SurfaceConfig`, `LayerConfig`, `Length`, `LitVars` | Types for configuration and inline styles. |
|
|
146
|
+
|
|
147
|
+
Layer settings are optional: inset defaults to `true`, color to white for the first layer and black for later layers, offsets/blur/spread to `0`, and alpha to `100` (percent). Supply offsets or blur to make an inset layer visible. The base class defaults to `<surface>-base`.
|
|
148
|
+
|
|
149
|
+
For a pointer-following **inset bezel**, use the configured surface name and a layer named `lit`:
|
|
150
|
+
|
|
151
|
+
```tsx
|
|
152
|
+
"use client"
|
|
153
|
+
|
|
154
|
+
import { useRef } from "react"
|
|
155
|
+
import { litVars } from "@exegia/specular"
|
|
156
|
+
import { usePointerLight } from "@exegia/specular/react"
|
|
157
|
+
|
|
158
|
+
export function LitButton() {
|
|
159
|
+
const ref = useRef<HTMLButtonElement>(null)
|
|
160
|
+
const angle = usePointerLight(ref)
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<button
|
|
164
|
+
ref={ref}
|
|
165
|
+
className="rounded-lg bg-stone-200 px-4 py-2 bezel-base"
|
|
166
|
+
style={litVars(angle, 2.5, "bezel")}
|
|
167
|
+
>
|
|
168
|
+
Move your pointer
|
|
169
|
+
</button>
|
|
170
|
+
)
|
|
102
171
|
}
|
|
103
172
|
```
|
|
104
173
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
|
124
|
-
|
|
|
125
|
-
| `
|
|
126
|
-
| `
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
last survives. That shipped once — the CSS compiled, a `toContain` test
|
|
159
|
-
passed on the survivor, and the lit layer never reached the screen.
|
|
160
|
-
|
|
161
|
-
`mergeConfig(surfaces)` generates one class group per custom property,
|
|
162
|
-
bidirectional conflicts with core `shadow`/`inset-shadow`, all-layer groups
|
|
163
|
-
that displace their per-layer twins, and the full cross-surface matrix (two
|
|
164
|
-
surfaces on one element means one `box-shadow` wins outright). Group ids are
|
|
165
|
-
a template-literal union, so a typo in your own `conflictingClassGroups` is a
|
|
166
|
-
compile error.
|
|
167
|
-
|
|
168
|
-
`createCn` accepts what tailwind-merge accepts (strings, arrays, falsy). For
|
|
169
|
-
clsx's object form: `extendTailwindMerge(mergeConfig(surfaces))(clsx(...))`.
|
|
170
|
-
|
|
171
|
-
## React
|
|
172
|
-
|
|
173
|
-
Every entry carries `"use client"`. SSR renders the static emboss; nothing
|
|
174
|
-
touches `window` at module scope.
|
|
174
|
+
This uses the Tailwind plugin but does not need `<Rim>`.
|
|
175
|
+
|
|
176
|
+
### `@exegia/specular/plugin` — generate Tailwind CSS
|
|
177
|
+
|
|
178
|
+
`createPlugin(surfaces, options?)` is the build-time factory used in setup step 1. It generates the surface utilities and dark-mode presets. It does not handle pointer movement or class merging, and it does not require React.
|
|
179
|
+
|
|
180
|
+
The `surfaces` argument is required; the second argument is optional. By default, the plugin also supplies `<Rim>` dark-mode tokens under `.dark`: `hi: 0.18`, `lo: 0.4`. You can customize those independently of your surfaces:
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
export default createPlugin(surfaces, {
|
|
184
|
+
rim: { darkSelector: ".dark", dark: { hi: 0.18, lo: 0.4 } },
|
|
185
|
+
})
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Use `{ rim: false }` to omit those tokens. `PluginOptions` and `RimTokens` are available as types.
|
|
189
|
+
|
|
190
|
+
### `@exegia/specular/merge` — merge class names correctly
|
|
191
|
+
|
|
192
|
+
| Export | When to use it |
|
|
193
|
+
| --- | --- |
|
|
194
|
+
| `createCn(surfaces)` | You want a ready-to-use `cn(...)` function that understands the configured utilities and their conflicts. |
|
|
195
|
+
| `mergeConfig(surfaces)` | You already customize tailwind-merge and want to incorporate Specular's class groups into that setup. Returns configuration, not a merging function. |
|
|
196
|
+
|
|
197
|
+
Both require the same surfaces passed to the plugin. They do not generate CSS or register the plugin. `createCn` accepts strings, arrays, and falsy values; it does **not** accept clsx-style objects.
|
|
198
|
+
|
|
199
|
+
The lower-level equivalent is:
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
import { extendTailwindMerge } from "tailwind-merge"
|
|
203
|
+
import { mergeConfig } from "@exegia/specular/merge"
|
|
204
|
+
import { surfaces } from "./specular.config.ts"
|
|
205
|
+
|
|
206
|
+
export const cn = extendTailwindMerge(mergeConfig(surfaces))
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
If your existing helper uses `clsx`, pass its output to this configured merger. `BezelGroupId` and `BezelMergeConfig` are available as types.
|
|
210
|
+
|
|
211
|
+
### `@exegia/specular/react` — React effects
|
|
212
|
+
|
|
213
|
+
**`<Rim>` draws the effect; `usePointerLight` provides movement.** They are independent: a rim can stay still, and the hook can drive a bezel or your own styles.
|
|
214
|
+
|
|
215
|
+
| Export | What it provides | Required setup |
|
|
216
|
+
| --- | --- | --- |
|
|
217
|
+
| `<Rim>` | A decorative gradient overlay, useful above images that would cover an inset shadow. Defaults to angle `0`. | A positioned parent and a border radius on the rim. |
|
|
218
|
+
| `usePointerLight(ref, options?)` | The pointer's angle around the referenced element, in degrees clockwise from the top. Renders nothing. | Attach the ref to the element you want to light. Options are optional. |
|
|
219
|
+
| `useRimTone(src, enabled?)` | Measures image-edge brightness as React state (`0`–`1`, or `null` while unavailable). | An image URL; enabled by default. |
|
|
220
|
+
| `measureRimTone(src)` | The same image measurement as a promise, for manual use instead of the hook. | Browser image/canvas support. |
|
|
221
|
+
| `toneToAlphas(tone, ends?)` | Converts brightness to `{ hi, lo }` props for `<Rim>`. | A numeric tone; custom endpoints are optional. |
|
|
222
|
+
| `useReducedMotion()` | Tracks the user's reduced-motion preference for your own effects. | No arguments. Already used internally by the pointer hook and rim. |
|
|
223
|
+
|
|
224
|
+
#### A pointer-following rim
|
|
225
|
+
|
|
226
|
+
This example needs React 19 and ordinary Tailwind utilities, but **no Specular plugin, surfaces, or `cn()` setup**:
|
|
175
227
|
|
|
176
228
|
```tsx
|
|
177
|
-
|
|
229
|
+
"use client"
|
|
230
|
+
|
|
231
|
+
import { useRef } from "react"
|
|
232
|
+
import { Rim, usePointerLight } from "@exegia/specular/react"
|
|
233
|
+
|
|
234
|
+
export function Orb() {
|
|
235
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
236
|
+
const angle = usePointerLight(ref)
|
|
178
237
|
|
|
179
|
-
function Avatar({ src }: { src: string }) {
|
|
180
|
-
const ref = useRef<HTMLSpanElement>(null)
|
|
181
|
-
const angle = usePointerLight(ref) // raw bearing, 0 = 12 o'clock
|
|
182
|
-
const tone = useRimTone(src) // 0–1 rim lightness, or null
|
|
183
|
-
const alphas = tone === null ? undefined : toneToAlphas(tone)
|
|
184
238
|
return (
|
|
185
|
-
<
|
|
186
|
-
<
|
|
187
|
-
|
|
188
|
-
</span>
|
|
239
|
+
<div ref={ref} className="relative size-20 rounded-full bg-stone-300">
|
|
240
|
+
<Rim angle={angle} className="rounded-full" />
|
|
241
|
+
</div>
|
|
189
242
|
)
|
|
190
243
|
}
|
|
191
244
|
```
|
|
192
245
|
|
|
193
|
-
-
|
|
194
|
-
rAF per frame for every subscriber on the page; 1.5° epsilon; shortest-arc
|
|
195
|
-
unwrapping so `rotate()` never spins the long way round; no subscription
|
|
196
|
-
under `prefers-reduced-motion`. Damping is yours (`angle * 0.8`).
|
|
197
|
-
`{ mode: "var", property: "--light-angle" }` writes `<angle>deg` on the
|
|
198
|
-
element with no re-render — the escape hatch for lists.
|
|
199
|
-
- **`<Rim>`** — the masked conic sweep, `rotate()` as the animated property,
|
|
200
|
-
radius-relative mask so it reads the same at 32px and 64px. Radius is
|
|
201
|
-
yours: pass `rounded-full`. Alphas come from `--rim-hi-a` / `--rim-lo-a`
|
|
202
|
-
(light 1 / 0.2 baked in; the plugin emits the dark 0.18 / 0.4 block).
|
|
203
|
-
- **`measureRimTone(src)`** — Rec. 709 luma over the outer band of a 16×16
|
|
204
|
-
sample; `null` on SSR, decode error or a host without CORS headers;
|
|
205
|
-
memoised per `src`, a `null` is evicted so a later mount retries.
|
|
206
|
-
- **`toneToAlphas(t, ends?)`** — `{ hi: 0.7 + 0.3t, lo: 0.6 − 0.4t }`,
|
|
207
|
-
retunable through `ends`.
|
|
208
|
-
|
|
209
|
-
## Things worth knowing
|
|
210
|
-
|
|
211
|
-
- **An inset shadow paints under the root's children.** A `size-full` image
|
|
212
|
-
hides it. That is why `<Rim>` is a separate overlay, and why a fallback
|
|
213
|
-
must stay transparent for the emboss to show.
|
|
214
|
-
- **The family owns `box-shadow`.** Never pair a surface with `shadow-*` or
|
|
215
|
-
`ring-*`; `cn()` deletes one, and raw class strings leave the stylesheet to
|
|
216
|
-
pick an arbitrary winner.
|
|
217
|
-
- **`S-blur-N` and `S-L-blur-N` write the same var.** In the stylesheet (and
|
|
218
|
-
in `@apply`) the winner is Tailwind's sort order, not class order. `cn()`
|
|
219
|
-
resolves it: all-layer displaces per-layer.
|
|
220
|
-
- **Browser floor: `color-mix()`** (Chrome 111, Safari 16.2, Firefox 113).
|
|
221
|
-
Tailwind's fallback branch drops the alpha, so below that the surface
|
|
222
|
-
paints opaque. Accepted: all three are 2023.
|
|
223
|
-
- **`@apply` of a surface inside `@layer components` works.**
|
|
224
|
-
- **Opposite edges of one layer overwrite; they do not add.** Two layers is
|
|
225
|
-
the way to light two edges.
|
|
226
|
-
|
|
227
|
-
## Migrating an existing `inset-shadow-*` family
|
|
228
|
-
|
|
229
|
-
A surface named `inset-shadow` with `baseClass: "inset-shadow-bezel"`,
|
|
230
|
-
`allowCoreCollision: true` and layers `lit` / `dim` reproduces the class API
|
|
231
|
-
exactly (`inset-shadow-lit-t-3 inset-shadow-lit/80 …`), and the merge config
|
|
232
|
-
handles the core collision. The tests cover this shape class by class.
|
|
233
|
-
|
|
234
|
-
## Example app
|
|
235
|
-
|
|
236
|
-
`examples/vite-react` is a Vite + React 19 + Tailwind v4 page that walks
|
|
237
|
-
through every surface, offset, alpha, blur and colour utility, the `glow`
|
|
238
|
-
hover halo, the pointer-lit rims and the `cn()` merge. It runs against the
|
|
239
|
-
library in this checkout:
|
|
246
|
+
For a static rim, omit the ref and hook and render `<Rim className="rounded-full" />`. The component positions itself absolutely; the parent needs `position: relative`. Its effect uses inline styles, so you can also supply sizing, background, and border radius with your own CSS instead of Tailwind.
|
|
240
247
|
|
|
241
|
-
|
|
242
|
-
make example-dev # build the library, install the example, start Vite
|
|
243
|
-
make example-build # production build, what CI runs
|
|
244
|
-
```
|
|
248
|
+
`<Rim>` accepts `hi` and `lo` strength overrides from `0` to `1`. Without overrides, it reads `--rim-hi-a` and `--rim-lo-a`, falling back to `1` and `0.2`. The Specular plugin supplies dark-mode values; without it, set the CSS variables or props yourself if you want theme-specific strengths.
|
|
245
249
|
|
|
246
|
-
|
|
247
|
-
(cards lift, glow buttons, press sink, pointer-following light and rims):
|
|
250
|
+
`usePointerLight(ref, { damping: 0.4 })` makes tracking more gradual; the default is `1`. Set `enabled: false` to stop tracking. For custom CSS effects, `{ mode: "var" }` writes `--light-angle` on the referenced element instead of returning an angle or rerendering on pointer changes; `property` can override that variable name. The hook stops subscribing under reduced motion, and `<Rim>` disables its rotation transition.
|
|
248
251
|
|
|
249
|
-
|
|
250
|
-
|
|
252
|
+
In apps with React Server Components, put components that call these hooks in a `"use client"` file, as shown above.
|
|
253
|
+
|
|
254
|
+
#### Optional: adapt a rim to an image
|
|
255
|
+
|
|
256
|
+
Tone helpers adjust highlight/shadow strength to the image's brightness. They are not required for pointer tracking or for `<Rim>` to render.
|
|
257
|
+
|
|
258
|
+
```tsx
|
|
259
|
+
"use client"
|
|
260
|
+
|
|
261
|
+
import { useRef } from "react"
|
|
262
|
+
import { Rim, toneToAlphas, usePointerLight, useRimTone } from "@exegia/specular/react"
|
|
263
|
+
|
|
264
|
+
export function LitAvatar({ src, alt }: { src: string; alt: string }) {
|
|
265
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
266
|
+
const angle = usePointerLight(ref)
|
|
267
|
+
const tone = useRimTone(src)
|
|
268
|
+
const alphas = tone === null ? {} : toneToAlphas(tone)
|
|
269
|
+
|
|
270
|
+
return (
|
|
271
|
+
<div ref={ref} className="relative size-20 rounded-full">
|
|
272
|
+
<img src={src} alt={alt} className="size-full rounded-full object-cover" />
|
|
273
|
+
<Rim angle={angle} {...alphas} className="rounded-full" />
|
|
274
|
+
</div>
|
|
275
|
+
)
|
|
276
|
+
}
|
|
251
277
|
```
|
|
252
278
|
|
|
253
|
-
|
|
254
|
-
|
|
279
|
+
`useRimTone` wraps `measureRimTone`; choose one, not both. Measurement returns `null` if the image cannot be sampled, including when a remote host does not allow CORS. The image can still display, and this example keeps the rim's default strengths until a tone is available.
|
|
280
|
+
|
|
281
|
+
`toneToAlphas` defaults to `{ hi: 0.7, lo: 0.6 }` for a black image edge and `{ hi: 1, lo: 0.2 }` for a white edge, interpolating between them. These explicit props override the rim's theme tokens.
|
|
282
|
+
|
|
283
|
+
The React entry point also exports `PointerLightOptions`, `RimProps`, `ToneAlphas`, and `ToneEnds` types, plus `ANGLE_EPSILON`, `RIM_TRANSITION`, and `DEFAULT_TONE_ENDS` constants for advanced use.
|
|
255
284
|
|
|
256
285
|
## Development
|
|
257
286
|
|
|
258
287
|
```bash
|
|
259
|
-
make ci
|
|
260
|
-
make
|
|
288
|
+
make ci
|
|
289
|
+
make example-dev
|
|
261
290
|
```
|
|
262
291
|
|
|
263
|
-
|
|
264
|
-
in memory in under a second. See `.github/WORKFLOW.md` for branching and
|
|
265
|
-
releases.
|
|
292
|
+
The [example site](examples/vite-react) is the complete visual reference.
|