Skip to content

Commit 966490e

Browse files
committed
feat: Added strict CSP mode that uses CSS-only
1 parent f339d71 commit 966490e

File tree

5 files changed

+283
-38
lines changed

5 files changed

+283
-38
lines changed

README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,48 @@ const App = () => {
6767
};
6868
```
6969

70+
## Content Security Policy (CSP)
71+
72+
react-hot-toast supports strict Content Security Policies through an opt-in strict CSP mode.
73+
74+
### Default Mode
75+
76+
By default, react-hot-toast uses inline styles for maximum flexibility. This requires `style-src 'unsafe-inline'` in your CSP.
77+
78+
### Strict CSP Mode
79+
80+
For applications with strict CSP that disallow inline styles, enable strict CSP mode:
81+
82+
```jsx
83+
import toast, { Toaster } from 'react-hot-toast';
84+
85+
<Toaster strictCSP={true} />
86+
```
87+
88+
In strict CSP mode:
89+
- All inline `style` props are ignored
90+
- Styling must be done via CSS classes and CSS variables
91+
- Toast positioning uses CSS flexbox instead of inline transforms
92+
- Fully compatible with CSP `style-src 'nonce-...'` directives
93+
94+
The library uses [goober](https://github.com/cristianbote/goober) for styling, which supports CSP nonces through multiple methods:
95+
96+
**Using `setNonce()`:**
97+
```js
98+
import { setNonce } from 'goober';
99+
setNonce('your-nonce-here');
100+
```
101+
102+
**Vite convention:**
103+
```html
104+
<meta property="csp-nonce" nonce="your-nonce-here" />
105+
```
106+
107+
**Webpack convention:**
108+
Set `window.__webpack_nonce__` in your entry script (see [webpack CSP guide](https://webpack.js.org/guides/csp/)).
109+
110+
Make sure your CSP includes `style-src 'nonce-your-nonce-here'` and `script-src 'nonce-your-nonce-here'`.
111+
70112
## Documentation
71113

72114
Find the full API reference on [official documentation](https://react-hot-toast.com/docs).

site/pages/docs/styling.mdx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,41 @@ import { Toaster, ToastBar } from 'react-hot-toast';
128128
)}
129129
</Toaster>;
130130
```
131+
132+
## Strict CSP Mode
133+
134+
For applications with strict Content Security Policies that disallow inline styles, enable `strictCSP` mode:
135+
136+
```jsx
137+
<Toaster strictCSP={true} />
138+
```
139+
140+
In strict CSP mode:
141+
- All inline `style` props are ignored
142+
- Styling must be done via CSS classes and CSS variables
143+
- Toast positioning uses CSS flexbox instead of inline transforms
144+
- The library is fully compatible with CSP `style-src 'nonce-...'` directives
145+
146+
### Styling with CSS Variables (Strict CSP)
147+
148+
When using strict CSP mode, use CSS variables for theming:
149+
150+
```css
151+
:root {
152+
/* Success toasts */
153+
--rht-success-bg: #ecfdf5;
154+
--rht-success-fg: #065f46;
155+
156+
/* Error toasts */
157+
--rht-error-bg: #fef2f2;
158+
--rht-error-fg: #991b1b;
159+
160+
/* Loading toasts */
161+
--rht-loading-bg: #eff6ff;
162+
--rht-loading-fg: #1e40af;
163+
164+
/* Blank toasts */
165+
--rht-blank-bg: #fff;
166+
--rht-blank-fg: #363636;
167+
}
168+
```

src/components/toast-bar.tsx

Lines changed: 113 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,10 @@
11
import * as React from 'react';
2-
import { styled, keyframes } from 'goober';
2+
import { styled } from 'goober';
33

44
import { Toast, ToastPosition, resolveValue, Renderable } from '../core/types';
55
import { ToastIcon } from './toast-icon';
66
import { prefersReducedMotion } from '../core/utils';
77

8-
const enterAnimation = (factor: number) => `
9-
0% {transform: translate3d(0,${factor * -200}%,0) scale(.6); opacity:.5;}
10-
100% {transform: translate3d(0,0,0) scale(1); opacity:1;}
11-
`;
12-
13-
const exitAnimation = (factor: number) => `
14-
0% {transform: translate3d(0,0,-1px) scale(1); opacity:1;}
15-
100% {transform: translate3d(0,${factor * -150}%,-1px) scale(.6); opacity:0;}
16-
`;
17-
18-
const fadeInAnimation = `0%{opacity:0;} 100%{opacity:1;}`;
19-
const fadeOutAnimation = `0%{opacity:1;} 100%{opacity:0;}`;
20-
218
// Use :where() for zero specificity - allows Tailwind to override easily
229
const ToastBarBase = styled('div')`
2310
:where(&) {
@@ -33,6 +20,84 @@ const ToastBarBase = styled('div')`
3320
padding: 8px 10px;
3421
border-radius: 8px;
3522
}
23+
24+
@keyframes rht-enter-from-top {
25+
0% { transform: translate3d(0, -200%, 0) scale(.6); opacity: .5; }
26+
100% { transform: translate3d(0, 0, 0) scale(1); opacity: 1; }
27+
}
28+
29+
@keyframes rht-enter-from-bottom {
30+
0% { transform: translate3d(0, 200%, 0) scale(.6); opacity: .5; }
31+
100% { transform: translate3d(0, 0, 0) scale(1); opacity: 1; }
32+
}
33+
34+
@keyframes rht-exit-to-top {
35+
0% { transform: translate3d(0, 0, -1px) scale(1); opacity: 1; }
36+
100% { transform: translate3d(0, -150%, -1px) scale(.6); opacity: 0; }
37+
}
38+
39+
@keyframes rht-exit-to-bottom {
40+
0% { transform: translate3d(0, 0, -1px) scale(1); opacity: 1; }
41+
100% { transform: translate3d(0, 150%, -1px) scale(.6); opacity: 0; }
42+
}
43+
44+
@keyframes rht-fade-in {
45+
0% { opacity: 0; }
46+
100% { opacity: 1; }
47+
}
48+
49+
@keyframes rht-fade-out {
50+
0% { opacity: 1; }
51+
100% { opacity: 0; }
52+
}
53+
54+
&.rht-enter-from-top {
55+
animation: rht-enter-from-top 0.35s cubic-bezier(.21,1.02,.73,1) forwards;
56+
}
57+
58+
&.rht-enter-from-bottom {
59+
animation: rht-enter-from-bottom 0.35s cubic-bezier(.21,1.02,.73,1) forwards;
60+
}
61+
62+
&.rht-exit-to-top {
63+
animation: rht-exit-to-top 0.4s forwards cubic-bezier(.06,.71,.55,1);
64+
}
65+
66+
&.rht-exit-to-bottom {
67+
animation: rht-exit-to-bottom 0.4s forwards cubic-bezier(.06,.71,.55,1);
68+
}
69+
70+
&.rht-fade-in {
71+
animation: rht-fade-in 0.35s cubic-bezier(.21,1.02,.73,1) forwards;
72+
}
73+
74+
&.rht-fade-out {
75+
animation: rht-fade-out 0.4s forwards cubic-bezier(.06,.71,.55,1);
76+
}
77+
78+
&.rht-invisible {
79+
opacity: 0;
80+
}
81+
82+
&.rht-success {
83+
background: var(--rht-success-bg, #ecfdf5);
84+
color: var(--rht-success-fg, #065f46);
85+
}
86+
87+
&.rht-error {
88+
background: var(--rht-error-bg, #fef2f2);
89+
color: var(--rht-error-fg, #991b1b);
90+
}
91+
92+
&.rht-loading {
93+
background: var(--rht-loading-bg, #fff);
94+
color: var(--rht-loading-fg, #363636);
95+
}
96+
97+
&.rht-blank {
98+
background: var(--rht-blank-bg, #fff);
99+
color: var(--rht-blank-fg, #363636);
100+
}
36101
`;
37102

38103
const Message = styled('div')`
@@ -48,38 +113,43 @@ interface ToastBarProps {
48113
toast: Toast;
49114
position?: ToastPosition;
50115
style?: React.CSSProperties;
116+
strictCSP?: boolean;
51117
children?: (components: {
52118
icon: Renderable;
53119
message: Renderable;
54120
}) => Renderable;
55121
}
56122

57-
const getAnimationStyle = (
123+
const getAnimationClass = (
58124
position: ToastPosition,
59-
visible: boolean
60-
): React.CSSProperties => {
125+
visible: boolean,
126+
hasHeight: boolean
127+
): string => {
128+
if (!hasHeight) {
129+
return 'rht-invisible';
130+
}
131+
61132
const top = position.includes('top');
62-
const factor = top ? 1 : -1;
133+
const reduced = prefersReducedMotion();
63134

64-
const [enter, exit] = prefersReducedMotion()
65-
? [fadeInAnimation, fadeOutAnimation]
66-
: [enterAnimation(factor), exitAnimation(factor)];
135+
if (reduced) {
136+
return visible ? 'rht-fade-in' : 'rht-fade-out';
137+
}
138+
139+
if (visible) {
140+
return top ? 'rht-enter-from-top' : 'rht-enter-from-bottom';
141+
}
67142

68-
return {
69-
animation: visible
70-
? `${keyframes(enter)} 0.35s cubic-bezier(.21,1.02,.73,1) forwards`
71-
: `${keyframes(exit)} 0.4s forwards cubic-bezier(.06,.71,.55,1)`,
72-
};
143+
return top ? 'rht-exit-to-top' : 'rht-exit-to-bottom';
73144
};
74145

75146
export const ToastBar: React.FC<ToastBarProps> = React.memo(
76-
({ toast, position, style, children }) => {
77-
const animationStyle: React.CSSProperties = toast.height
78-
? getAnimationStyle(
79-
toast.position || position || 'top-center',
80-
toast.visible
81-
)
82-
: { opacity: 0 };
147+
({ toast, position, style, strictCSP, children }) => {
148+
const animationClass = getAnimationClass(
149+
toast.position || position || 'top-center',
150+
toast.visible,
151+
!!toast.height
152+
);
83153

84154
const icon = <ToastIcon toast={toast} />;
85155
const message = (
@@ -88,11 +158,18 @@ export const ToastBar: React.FC<ToastBarProps> = React.memo(
88158
</Message>
89159
);
90160

161+
const className = [
162+
toast.className,
163+
animationClass,
164+
toast.type ? `rht-${toast.type}` : null,
165+
]
166+
.filter(Boolean)
167+
.join(' ');
168+
91169
return (
92170
<ToastBarBase
93-
className={toast.className}
94-
style={{
95-
...animationStyle,
171+
className={className}
172+
style={strictCSP ? undefined : {
96173
...style,
97174
...toast.style,
98175
}}

src/components/toaster.tsx

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { css, setup } from 'goober';
1+
import { styled, setup, css } from 'goober';
22
import * as React from 'react';
33
import {
44
resolveValue,
@@ -81,19 +81,106 @@ const activeClass = css`
8181
`;
8282

8383
const DEFAULT_OFFSET = 16;
84+
const DEFAULT_GUTTER = 8;
85+
86+
const ToasterContainer = styled('div')`
87+
position: fixed;
88+
z-index: 9999;
89+
top: ${DEFAULT_OFFSET}px;
90+
left: ${DEFAULT_OFFSET}px;
91+
right: ${DEFAULT_OFFSET}px;
92+
bottom: ${DEFAULT_OFFSET}px;
93+
pointer-events: none;
94+
display: flex;
95+
flex-direction: column;
96+
gap: ${DEFAULT_GUTTER}px;
97+
98+
&[data-position="top-left"],
99+
&[data-position="top-center"],
100+
&[data-position="top-right"] {
101+
align-items: flex-start;
102+
}
103+
104+
&[data-position="bottom-left"],
105+
&[data-position="bottom-center"],
106+
&[data-position="bottom-right"] {
107+
align-items: flex-start;
108+
flex-direction: column-reverse;
109+
}
110+
111+
&[data-position="top-center"],
112+
&[data-position="bottom-center"] {
113+
align-items: center;
114+
}
115+
116+
&[data-position="top-right"],
117+
&[data-position="bottom-right"] {
118+
align-items: flex-end;
119+
}
120+
121+
> * {
122+
pointer-events: auto;
123+
}
124+
125+
@media (prefers-reduced-motion: reduce) {
126+
* {
127+
transition: none !important;
128+
animation: none !important;
129+
}
130+
}
131+
`;
84132

85133
export const Toaster: React.FC<ToasterProps> = ({
86134
reverseOrder,
87135
position = 'top-center',
88136
toastOptions,
89-
gutter,
137+
gutter = DEFAULT_GUTTER,
90138
children,
91139
toasterId,
92140
containerStyle,
93141
containerClassName,
142+
strictCSP = false,
94143
}) => {
95144
const { toasts, handlers } = useToaster(toastOptions, toasterId);
96145

146+
// Sort toasts based on reverseOrder
147+
const sortedToasts = reverseOrder ? [...toasts].reverse() : toasts;
148+
149+
// Strict CSP mode: Use styled component with no inline styles
150+
if (strictCSP) {
151+
return (
152+
<ToasterContainer
153+
data-rht-toaster={toasterId || ''}
154+
data-position={position}
155+
className={containerClassName}
156+
onMouseEnter={handlers.startPause}
157+
onMouseLeave={handlers.endPause}
158+
>
159+
{sortedToasts.map((t) => {
160+
const toastPosition = t.position || position;
161+
162+
return (
163+
<ToastWrapper
164+
id={t.id}
165+
key={t.id}
166+
onHeightUpdate={handlers.updateHeight}
167+
className=""
168+
>
169+
{t.type === 'custom' ? (
170+
resolveValue(t.message, t)
171+
) : children ? (
172+
children(t)
173+
) : (
174+
<ToastBar toast={t} position={toastPosition} strictCSP />
175+
)}
176+
</ToastWrapper>
177+
);
178+
})}
179+
</ToasterContainer>
180+
);
181+
}
182+
183+
// Default mode: Use inline styles for maximum flexibility
97184
return (
98185
<div
99186
data-rht-toaster={toasterId || ''}

0 commit comments

Comments
 (0)