Skip to content

Commit fbe28d0

Browse files
feat: add dark theme support to stream-chat-react (#3002)
## 🎯 Goal Add dark theme support to `stream-chat-react` and wire the Vite example so the SDK can be exercised in both light and dark mode. Linear ticket: https://linear.app/stream/issue/REACT-798/add-support-for-dark-theme ## 🛠 Implementation details - add dark semantic token overrides for the React SDK and scope them to `str-chat__theme-dark` - make `Chat`/portal rendering respect the dark theme class and update the Vite example with a theme toggle - align loading shimmers and image-loading overlays with the theme-aware skeleton tokens ## 🎨 UI Changes https://github.com/user-attachments/assets/5e05301c-47c7-421f-8d1a-5af4eb778ef5 --------- Co-authored-by: Anton Arnautov <arnautov.anton@gmail.com>
1 parent 2a90233 commit fbe28d0

16 files changed

Lines changed: 845 additions & 556 deletions

File tree

examples/vite/src/App.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,9 @@ import { init, SearchIndex } from 'emoji-mart';
4646
import data from '@emoji-mart/data/sets/14/native.json';
4747
import { humanId } from 'human-id';
4848
import { chatViewSelectorItemSet } from './Sidebar/ChatViewSelectorItemSet.tsx';
49-
import { useAppSettingsState } from './AppSettings';
5049

5150
import { Search } from 'stream-chat-react/experimental';
51+
import { useAppSettingsState } from './AppSettings/state.ts';
5252

5353
init({ data });
5454

@@ -199,9 +199,17 @@ const CustomMessageReactions = (props: React.ComponentProps<typeof ReactionsList
199199
);
200200
};
201201

202+
const EmojiPickerWithCustomOptions = (
203+
props: React.ComponentProps<typeof EmojiPicker>,
204+
) => {
205+
const state = useAppSettingsState();
206+
207+
return <EmojiPicker {...props} pickerProps={{ theme: state.theme.mode }} />;
208+
};
209+
202210
const App = () => {
203211
const { userId, tokenProvider } = useUser();
204-
const { chatView } = useAppSettingsState();
212+
const { chatView, theme } = useAppSettingsState();
205213
const initialChannelId = useMemo(() => getSelectedChannelIdFromUrl(), []);
206214
const initialChatView = useMemo(() => getSelectedChatViewFromUrl(), []);
207215

@@ -288,11 +296,13 @@ const App = () => {
288296

289297
if (!chatClient) return <>Loading...</>;
290298

299+
const chatTheme = theme.mode === 'dark' ? 'str-chat__theme-dark' : 'messaging light';
300+
291301
return (
292302
<WithComponents
293303
overrides={{
294304
emojiSearchIndex: SearchIndex,
295-
EmojiPicker,
305+
EmojiPicker: EmojiPickerWithCustomOptions,
296306
ReactionsList: CustomMessageReactions,
297307
reactionOptions: newReactionOptions,
298308
}}
@@ -301,6 +311,7 @@ const App = () => {
301311
searchController={searchController}
302312
client={chatClient}
303313
isMessageAIGenerated={isMessageAIGenerated}
314+
theme={chatTheme}
304315
>
305316
<ChatView>
306317
<ChatStateSync initialChatView={initialChatView} />

examples/vite/src/AppSettings/AppSettings.scss

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,6 @@
88

99
.app__settings-group_button {
1010
color: var(--text-secondary);
11-
12-
svg {
13-
height: 2rem;
14-
width: 2rem;
15-
}
1611
}
1712
}
1813

@@ -22,7 +17,9 @@
2217
width: min(920px, 90vw);
2318
max-height: min(80vh, 760px);
2419
min-height: min(520px, 72vh);
25-
background: #fff;
20+
background: var(--background-elevation-elevation-2);
21+
color: var(--text-primary);
22+
border: 1px solid var(--border-core-default);
2623
border-radius: 14px;
2724
}
2825

@@ -33,7 +30,7 @@
3330
padding: 16px 20px;
3431
font-size: 1.5rem;
3532
font-weight: 700;
36-
border-bottom: 1px solid #dfe5ef;
33+
border-bottom: 1px solid var(--border-core-default);
3734

3835
svg.str-chat__icon--cog {
3936
height: 1.75rem;
@@ -51,7 +48,7 @@
5148
.app__settings-modal__tabs {
5249
overflow-y: auto;
5350
overscroll-behavior: contain;
54-
border-right: 1px solid #dfe5ef;
51+
border-right: 1px solid var(--border-core-default);
5552
padding: 10px;
5653
}
5754

@@ -61,12 +58,14 @@
6158
justify-content: flex-start;
6259
font-weight: 500;
6360
margin-bottom: 6px;
61+
color: var(--text-secondary);
6462
}
6563

6664
.app__settings-modal__tab[aria-selected='true'],
6765
.app__settings-modal__tab.app__settings-modal__tab--active {
68-
background: #e9f0ff;
69-
border-color: #3167f6;
66+
background: var(--background-core-selected);
67+
border-color: var(--border-utility-selected);
68+
color: var(--text-primary);
7069
font-weight: 600;
7170
}
7271

@@ -90,7 +89,7 @@
9089

9190
.app__settings-modal__field-label {
9291
font-weight: 600;
93-
color: #2f3550;
92+
color: var(--text-primary);
9493
}
9594

9695
.app__settings-modal__options-row {
@@ -100,8 +99,8 @@
10099
}
101100

102101
.app__settings-modal__option-button[aria-pressed='true'] {
103-
border-color: #3167f6;
104-
background: #e9f0ff;
102+
border-color: var(--border-utility-selected);
103+
background: var(--background-core-selected);
105104
font-weight: 600;
106105
}
107106

@@ -113,10 +112,10 @@
113112
}
114113

115114
.app__settings-modal__preview {
116-
border: 1px solid var(--border);
115+
border: 1px solid var(--border-core-default);
117116
border-radius: 12px;
118117
padding: 12px;
119-
background: var(--bg-surface);
118+
background: var(--background-core-surface);
120119

121120
.str-chat__li--single {
122121
list-style: none;
@@ -141,7 +140,7 @@
141140

142141
.app__settings-modal__tabs {
143142
border-right: 0;
144-
border-bottom: 1px solid #dfe5ef;
143+
border-bottom: 1px solid var(--border-core-default);
145144
display: flex;
146145
gap: 8px;
147146
padding: 10px 12px;

examples/vite/src/AppSettings/AppSettings.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import {
44
GlobalModal,
55
IconBubble3ChatMessage,
66
IconEmojiSmile,
7+
IconLightBulbSimple,
78
IconSettingsGear2,
89
} from 'stream-chat-react';
910
import { type ComponentType, useState } from 'react';
1011
import { ReactionsTab } from './tabs/Reactions';
1112
import { SidebarTab } from './tabs/Sidebar';
13+
import { appSettingsStore, useAppSettingsState } from './state';
1214

1315
type TabId = 'reactions' | 'sidebar';
1416

@@ -17,13 +19,41 @@ const tabConfig: { Icon: ComponentType; id: TabId; title: string }[] = [
1719
{ Icon: IconEmojiSmile, id: 'reactions', title: 'Reactions' },
1820
];
1921

22+
const SidebarThemeToggle = ({ iconOnly = true }: { iconOnly?: boolean }) => {
23+
const {
24+
theme: { mode },
25+
} = useAppSettingsState();
26+
const nextMode = mode === 'dark' ? 'light' : 'dark';
27+
28+
return (
29+
<ChatViewSelectorButton
30+
aria-checked={mode === 'dark'}
31+
aria-label={`Switch to ${nextMode} mode`}
32+
aria-selected={mode === 'dark'}
33+
className='app__settings-group_button'
34+
iconOnly={iconOnly}
35+
Icon={IconLightBulbSimple}
36+
isActive={mode === 'dark'}
37+
onClick={() =>
38+
appSettingsStore.partialNext({
39+
theme: { mode: nextMode },
40+
})
41+
}
42+
role='switch'
43+
text={mode === 'dark' ? 'Dark mode' : 'Light mode'}
44+
/>
45+
);
46+
};
47+
2048
export const AppSettings = ({ iconOnly = true }: { iconOnly?: boolean }) => {
2149
const [activeTab, setActiveTab] = useState<TabId>('sidebar');
2250
const [open, setOpen] = useState(false);
2351

2452
return (
2553
<div className='app__settings-group'>
54+
<SidebarThemeToggle iconOnly={iconOnly} />
2655
<ChatViewSelectorButton
56+
className='app__settings-group_button'
2757
iconOnly={iconOnly}
2858
Icon={IconSettingsGear2}
2959
onClick={() => setOpen(true)}

examples/vite/src/AppSettings/state.ts

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,19 @@ export type ChatViewSettingsState = {
1111
iconOnly: boolean;
1212
};
1313

14+
export type ThemeSettingsState = {
15+
mode: 'dark' | 'light';
16+
};
17+
1418
export type AppSettingsState = {
1519
chatView: ChatViewSettingsState;
1620
reactions: ReactionsSettingsState;
21+
theme: ThemeSettingsState;
1722
};
1823

24+
const themeStorageKey = 'stream-chat-react:example-theme-mode';
25+
const themeUrlParam = 'theme';
26+
1927
const defaultAppSettingsState: AppSettingsState = {
2028
chatView: {
2129
iconOnly: true,
@@ -25,10 +33,82 @@ const defaultAppSettingsState: AppSettingsState = {
2533
verticalPosition: 'top',
2634
visualStyle: 'clustered',
2735
},
36+
theme: {
37+
mode: 'light',
38+
},
39+
};
40+
41+
const getStoredThemeMode = (): ThemeSettingsState['mode'] | undefined => {
42+
if (typeof window === 'undefined') return;
43+
44+
let storedThemeMode: string | null = null;
45+
46+
try {
47+
storedThemeMode = window.localStorage.getItem(themeStorageKey);
48+
} catch {
49+
return;
50+
}
51+
52+
if (storedThemeMode === 'dark' || storedThemeMode === 'light') {
53+
return storedThemeMode;
54+
}
2855
};
2956

30-
export const appSettingsStore = new StateStore<AppSettingsState>(defaultAppSettingsState);
57+
const getThemeModeFromUrl = (): ThemeSettingsState['mode'] | undefined => {
58+
if (typeof window === 'undefined') return;
59+
60+
const themeMode = new URLSearchParams(window.location.search).get(themeUrlParam);
61+
62+
if (themeMode === 'dark' || themeMode === 'light') {
63+
return themeMode;
64+
}
65+
};
66+
67+
const persistThemeMode = (themeMode: ThemeSettingsState['mode']) => {
68+
if (typeof window === 'undefined') return;
69+
70+
try {
71+
window.localStorage.setItem(themeStorageKey, themeMode);
72+
} catch {
73+
// ignore persistence failures in environments where localStorage is unavailable
74+
}
75+
};
76+
77+
const persistThemeModeInUrl = (themeMode: ThemeSettingsState['mode']) => {
78+
if (typeof window === 'undefined') return;
79+
80+
const url = new URL(window.location.href);
81+
82+
if (url.searchParams.get(themeUrlParam) === themeMode) return;
83+
84+
url.searchParams.set(themeUrlParam, themeMode);
85+
86+
window.history.replaceState(
87+
window.history.state,
88+
'',
89+
`${url.pathname}${url.search}${url.hash}`,
90+
);
91+
};
92+
93+
const initialAppSettingsState: AppSettingsState = {
94+
...defaultAppSettingsState,
95+
theme: {
96+
...defaultAppSettingsState.theme,
97+
mode:
98+
getThemeModeFromUrl() ?? getStoredThemeMode() ?? defaultAppSettingsState.theme.mode,
99+
},
100+
};
101+
102+
export const appSettingsStore = new StateStore<AppSettingsState>(initialAppSettingsState);
103+
104+
appSettingsStore.subscribeWithSelector(
105+
({ theme }) => ({ mode: theme.mode }),
106+
({ mode }) => {
107+
persistThemeMode(mode);
108+
persistThemeModeInUrl(mode);
109+
},
110+
);
31111

32112
export const useAppSettingsState = () =>
33113
useStateStore(appSettingsStore, (nextValue: AppSettingsState) => nextValue) ??
34-
defaultAppSettingsState;
114+
initialAppSettingsState;

src/components/Attachment/styling/ModalGallery.scss

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
--str-chat__modal-gallery-load-failed-indicator-background: var(--accent-error);
55
--str-chat__modal-gallery-load-failed-indicator-color: var(--text-inverse);
66
--str-chat__modal-gallery-loading-background: var(--chat-bg-incoming);
7-
--str-chat__modal-gallery-loading-highlight: var(--base-white);
7+
--str-chat__modal-gallery-loading-base: var(--skeleton-loading-base);
8+
--str-chat__modal-gallery-loading-highlight: var(--skeleton-loading-highlight);
89
}
910

1011
.str-chat__message--me {
@@ -140,9 +141,9 @@
140141
background-color: var(--str-chat__modal-gallery-loading-background);
141142
background-image: linear-gradient(
142143
90deg,
143-
rgba(255, 255, 255, 0) 0%,
144+
var(--str-chat__modal-gallery-loading-base) 0%,
144145
var(--str-chat__modal-gallery-loading-highlight) 50%,
145-
rgba(255, 255, 255, 0) 100%
146+
var(--str-chat__modal-gallery-loading-base) 100%
146147
);
147148
background-repeat: no-repeat;
148149
background-size: 200% 100%;

src/components/Channel/styling/Channel.scss

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,8 +206,8 @@
206206
/* The icon color used when no channel is selected */
207207
--str-chat__channel-empty-indicator-color: var(--str-chat__disabled-color);
208208

209-
/* The color of the loading indicator */
210-
--str-chat__channel-loading-state-color: var(--slate-100);
209+
/* The base surface color behind the loading shimmer */
210+
--str-chat__channel-loading-state-color: var(--background-core-surface);
211211
}
212212

213213
.str-chat__channel {

src/components/Chat/__tests__/Chat.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ describe('Chat', () => {
7070
});
7171

7272
it('props change should update the context', async () => {
73-
const theme = 'team dark';
73+
const theme = 'str-chat__theme-dark';
7474
let context;
7575
const { rerender } = render(
7676
<Chat client={chatClient} theme={theme}>
@@ -86,7 +86,7 @@ describe('Chat', () => {
8686
expect(context.theme).toBe(theme);
8787
});
8888

89-
const newTheme = 'messaging dark';
89+
const newTheme = 'str-chat__theme-dark custom-theme';
9090
const newClient = getTestClient();
9191
rerender(
9292
<Chat client={newClient} theme={newTheme}>

src/components/Icons/icons.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ export const IconBookmark = createIcon(
110110
<path
111111
d='M12.8333 13.501V3.16666C12.8333 2.43028 12.2364 1.83333 11.5 1.83333H4.49999C3.76361 1.83333 3.16666 2.43028 3.16666 3.16666V13.501C3.16666 14.0348 3.76275 14.3521 4.20558 14.054L7.25546 12.0011C7.70559 11.6982 8.29439 11.6982 8.74452 12.0011L11.7944 14.054C12.2373 14.3521 12.8333 14.0348 12.8333 13.501Z'
112112
fill='none'
113-
stroke='black'
113+
stroke='currentColor'
114114
strokeLinecap='round'
115115
strokeLinejoin='round'
116116
/>,
@@ -364,14 +364,14 @@ export const IconCloseQuote2 = createIcon(
364364
<path
365365
d='M5.50001 3.16666H3.16668C2.4303 3.16666 1.83334 3.76361 1.83334 4.49999V7.35712C1.83334 8.09352 2.4303 8.69046 3.16668 8.69046H4.16668V12.8333C4.16668 12.8333 6.83334 11.7976 6.83334 8.69046V4.49907C6.83334 3.76269 6.23639 3.16666 5.50001 3.16666Z'
366366
fill='none'
367-
stroke='black'
367+
stroke='currentColor'
368368
strokeLinejoin='round'
369369
strokeWidth='1.2'
370370
/>
371371
<path
372372
d='M12.8333 3.16666H10.5C9.76361 3.16666 9.16668 3.76361 9.16668 4.49999V7.35712C9.16668 8.09352 9.76361 8.69046 10.5 8.69046H11.5V12.8333C11.5 12.8333 14.1667 11.7976 14.1667 8.69046V4.49907C14.1667 3.76269 13.5697 3.16666 12.8333 3.16666Z'
373373
fill='none'
374-
stroke='black'
374+
stroke='currentColor'
375375
strokeLinejoin='round'
376376
strokeWidth='1.2'
377377
/>

0 commit comments

Comments
 (0)