Skip to content

Commit e8d1fcb

Browse files
committed
feat: add animated gradient border effect with customizable speed options
1 parent 313d332 commit e8d1fcb

7 files changed

Lines changed: 107 additions & 8 deletions

File tree

packages/react/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# @protohiro/effects
22

3+
## 0.4.0
4+
5+
### Minor Changes
6+
7+
- Add animated gradient border flow with `animated` and `speed` options, CSS-only conic-gradient motion, reduced-motion fallback, and updated docs/tests.
8+
39
## 0.3.4
410

511
### Patch Changes

packages/react/README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ Peer dependencies:
2828
import { useGradientBorderEffect } from '@protohiro/effects';
2929

3030
export function Button() {
31-
const ref = useGradientBorderEffect({ thickness: 2, angle: 90 });
31+
const ref = useGradientBorderEffect({ thickness: 2, angle: 90, animated: true });
3232
return <button ref={ref}>Click</button>;
3333
}
3434
```
@@ -59,6 +59,18 @@ export function Button() {
5959

6060
Gradient border ring on a single existing element with `border-radius` support and graceful fallback behavior.
6161

62+
Options:
63+
64+
- `thickness?: string | number`
65+
- `radius?: string | number`
66+
- `colors?: string`
67+
- `angle?: string | number`
68+
- `animated?: boolean`
69+
- `speed?: number`
70+
- `disabled?: boolean`
71+
72+
When `animated` is enabled, the gradient colors flow around the border with CSS only. `speed` is a multiplier: `1` is the default, values above `1` are faster, and values below `1` are slower. The effect respects `prefers-reduced-motion` and falls back to a static ring when motion is reduced.
73+
6274
### `useSpotlightEffect(options)`
6375

6476
Interactive spotlight and reveal overlay for cards, media surfaces, and CTA blocks.

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.3.4",
3+
"version": "0.4.0",
44
"description": "React hooks for hard CSS effects like gradient borders, spotlight reveal, and glass highlights without wrappers.",
55
"license": "MIT",
66
"type": "module",

packages/react/src/effects/useGradientBorderEffect.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,20 @@ const STYLE_ID = 'protohiro-effects-base';
1010
export function useGradientBorderEffect<T extends HTMLElement = HTMLElement>(
1111
options: GradientBorderOptions = {},
1212
) {
13+
const speed = typeof options.speed === 'number' && Number.isFinite(options.speed) ? options.speed : 1;
14+
const resolvedSpeed = speed > 0 ? speed : 1;
15+
const animationDuration = `${6 / resolvedSpeed}s`;
16+
1317
const vars = useMemo(
1418
() => ({
1519
'--pe-gb-thickness': toCssLength(options.thickness),
1620
'--pe-gb-radius': toCssLength(options.radius),
1721
'--pe-gb-colors': options.colors,
1822
'--pe-gb-angle': toCssAngle(options.angle),
23+
'--pe-gb-animation-duration': animationDuration,
24+
'--pe-gb-animation-name': options.animated ? 'pe-gradient-border-flow' : 'none',
1925
}),
20-
[options.thickness, options.radius, options.colors, options.angle],
26+
[options.thickness, options.radius, options.colors, options.angle, animationDuration, options.animated],
2127
);
2228

2329
return useCssEffect<T>({

packages/react/src/index.test.tsx

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,13 @@ import {
1111
useSpotlightEffect,
1212
} from './index';
1313

14-
function GradientProbe(props: { thickness?: number }) {
15-
const ref = useGradientBorderEffect({ thickness: props.thickness ?? 3, colors: '#f00, #00f' });
14+
function GradientProbe(props: { thickness?: number; animated?: boolean; speed?: number }) {
15+
const ref = useGradientBorderEffect({
16+
thickness: props.thickness ?? 3,
17+
colors: '#f00, #00f',
18+
animated: props.animated,
19+
speed: props.speed,
20+
});
1621
return <button ref={ref}>Test</button>;
1722
}
1823

@@ -68,17 +73,51 @@ function ComposedProbe() {
6873

6974
describe('react effect hooks', () => {
7075
it('attaches and detaches class and vars', () => {
71-
const { container, unmount } = render(<GradientProbe thickness={4} />);
76+
const { container, unmount } = render(<GradientProbe thickness={4} animated speed={2} />);
7277
const button = container.querySelector('button');
7378

7479
expect(button).not.toBeNull();
7580
expect(button?.classList.contains('pe-gradient-border')).toBe(true);
7681
expect(button?.style.getPropertyValue('--pe-gb-thickness')).toBe('4px');
82+
expect(button?.style.getPropertyValue('--pe-gb-animation-duration')).toBe('3s');
83+
expect(button?.style.getPropertyValue('--pe-gb-animation-name')).toBe('pe-gradient-border-flow');
7784

7885
unmount();
7986

8087
expect(button?.classList.contains('pe-gradient-border')).toBe(false);
8188
expect(button?.style.getPropertyValue('--pe-gb-thickness')).toBe('');
89+
expect(button?.style.getPropertyValue('--pe-gb-animation-duration')).toBe('');
90+
expect(button?.style.getPropertyValue('--pe-gb-animation-name')).toBe('');
91+
});
92+
93+
it('toggles gradient border animation via options change', () => {
94+
function Probe() {
95+
const [animated, setAnimated] = useState(false);
96+
const [speed, setSpeed] = useState(1);
97+
const ref = useGradientBorderEffect({ thickness: 2, colors: '#f00, #00f', animated, speed });
98+
99+
return (
100+
<>
101+
<button ref={ref}>Test</button>
102+
<button onClick={() => setAnimated((value) => !value)}>Toggle</button>
103+
<button onClick={() => setSpeed(2)}>Faster</button>
104+
</>
105+
);
106+
}
107+
108+
const { container, getByRole } = render(<Probe />);
109+
const target = container.querySelector('button');
110+
111+
expect(target?.style.getPropertyValue('--pe-gb-animation-duration')).toBe('6s');
112+
expect(target?.style.getPropertyValue('--pe-gb-animation-name')).toBe('none');
113+
114+
fireEvent.click(getByRole('button', { name: 'Toggle' }));
115+
116+
expect(target?.style.getPropertyValue('--pe-gb-animation-name')).toBe('pe-gradient-border-flow');
117+
118+
fireEvent.click(getByRole('button', { name: 'Faster' }));
119+
120+
expect(target?.style.getPropertyValue('--pe-gb-animation-duration')).toBe('3s');
82121
});
83122

84123
it('updates css vars on options change', () => {

packages/react/src/shared/effectStyles.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,15 @@ export const EFFECT_STYLES = `
1414
border-radius: calc(var(--pe-gb-radius, 0px) + var(--pe-gb-thickness, 2px));
1515
box-sizing: border-box;
1616
padding: var(--pe-gb-thickness, 2px);
17-
background: linear-gradient(var(--pe-gb-angle, 120deg), var(--pe-gb-colors, #5eead4, #0ea5e9));
17+
background: conic-gradient(
18+
from calc(var(--pe-gb-angle, 120deg) + var(--pe-gb-flow-progress, 0) * 1turn),
19+
var(--pe-gb-colors, #5eead4, #0ea5e9)
20+
);
1821
pointer-events: none;
22+
animation-name: var(--pe-gb-animation-name, none);
23+
animation-duration: var(--pe-gb-animation-duration, 6s);
24+
animation-timing-function: linear;
25+
animation-iteration-count: infinite;
1926
2027
/* Hollow out the center so only the ring remains. */
2128
-webkit-mask:
@@ -28,14 +35,41 @@ export const EFFECT_STYLES = `
2835
mask-composite: exclude;
2936
}
3037
38+
@property --pe-gb-flow-progress {
39+
syntax: '<number>';
40+
inherits: false;
41+
initial-value: 0;
42+
}
43+
44+
@keyframes pe-gradient-border-flow {
45+
from {
46+
--pe-gb-flow-progress: 0;
47+
}
48+
49+
to {
50+
--pe-gb-flow-progress: 1;
51+
}
52+
}
53+
54+
@media (prefers-reduced-motion: reduce) {
55+
.pe-gradient-border::before {
56+
animation: none;
57+
will-change: auto;
58+
}
59+
}
60+
3161
@supports not ((-webkit-mask-composite: xor) or (mask-composite: exclude)) {
3262
.pe-gradient-border::before {
3363
z-index: -1;
3464
inset: calc(var(--pe-gb-thickness, 2px) * -1);
3565
border-radius: calc(var(--pe-gb-radius, 0px) + var(--pe-gb-thickness, 2px));
3666
padding: 0;
3767
border: var(--pe-gb-thickness, 2px) solid;
38-
border-image: linear-gradient(var(--pe-gb-angle, 120deg), var(--pe-gb-colors, #5eead4, #0ea5e9)) 1;
68+
border-image: conic-gradient(
69+
from calc(var(--pe-gb-angle, 120deg) + var(--pe-gb-flow-progress, 0) * 1turn),
70+
var(--pe-gb-colors, #5eead4, #0ea5e9)
71+
)
72+
1;
3973
}
4074
}
4175

packages/react/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ export interface GradientBorderOptions extends EffectBaseOptions {
88
radius?: string | number;
99
colors?: string;
1010
angle?: string | number;
11+
animated?: boolean;
12+
speed?: number;
1113
}
1214

1315
export interface GlowOptions extends EffectBaseOptions {

0 commit comments

Comments
 (0)