Skip to content

Commit 399fa2b

Browse files
committed
feat(theme): add dark mode support with system detection and smooth transitions
- Implement theme provider with next-themes for system preference detection - Add theme toggle component with system/light/dark options - Update globals.css with smooth color transitions respecting reduced motion - Reorganize style controls UI with theme toggle and reset button - Document new dark mode features in README
1 parent d594348 commit 399fa2b

File tree

7 files changed

+153
-23
lines changed

7 files changed

+153
-23
lines changed

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ A powerful web application that allows you to select components from the Shadcn
1212
- **Syntax Highlighting**: Beautiful code display with syntax highlighting
1313
- **Responsive Design**: Works seamlessly across different screen sizes
1414
- **Split-Pane Interface**: Efficient workspace with resizable panels
15+
- **Dark Mode**: System-aware theming with manual override (light/dark/system), smooth transitions, and accessible color palettes
1516

1617
## Tech Stack
1718

@@ -56,15 +57,26 @@ npm run dev
5657
3. **Preview Changes**: See your customized component in the center panel
5758
4. **Export Code**: Switch to the Export tab to copy or download the generated code
5859

60+
### Dark Mode & Accessibility
61+
- The app respects your OS preference using system detection and provides a Theme toggle (System/Light/Dark) in the customization panel.
62+
- Color palettes use OKLCH variables for better perceptual consistency and maintain WCAG AA contrast (≥ 4.5:1 for body text; ≥ 3:1 for large text).
63+
- Smooth color transitions are enabled and automatically disabled when users prefer reduced motion.
64+
65+
### Customization UI
66+
- Style Controls now prioritize Reset and basic tools at the top for quick changes.
67+
- Advanced options (borders, hover effects, shadows, spacing, alignment) are grouped under an Accordion with clear indicators and animations.
68+
5969
## Project Structure
6070

6171
```
6272
src/
6373
├── app/ # Next.js App Router
6474
│ ├── layout.tsx # Root layout
75+
│ ├── providers.tsx # Client providers (ThemeProvider)
6576
│ └── page.tsx # Home page
6677
├── components/
6778
│ ├── ui/ # shadcn/ui components
79+
│ │ └── theme-toggle.tsx # Theme manual override selector
6880
│ ├── features/ # Feature components
6981
│ │ ├── component-selector.tsx
7082
│ │ ├── component-preview.tsx

src/app/globals.css

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,19 @@
118118
}
119119
body {
120120
@apply bg-background text-foreground;
121+
/* Smooth theme transitions while respecting reduced motion */
122+
transition: background-color 200ms ease, color 200ms ease;
123+
}
124+
}
125+
126+
/* Global smooth color transitions for common surfaces */
127+
html, body, .bg-background, .bg-card, .bg-popover {
128+
transition: background-color 200ms ease, color 200ms ease, border-color 200ms ease;
129+
}
130+
131+
@media (prefers-reduced-motion: reduce) {
132+
html, body, .bg-background, .bg-card, .bg-popover {
133+
transition: none;
121134
}
122135
}
123136

src/app/layout.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
22
import { Geist, Geist_Mono } from "next/font/google";
33
import { Toaster } from "@/components/ui/sonner";
44
import "./globals.css";
5+
import { Providers } from "./providers";
56

67
const geistSans = Geist({
78
variable: "--font-geist-sans",
@@ -24,14 +25,16 @@ export default function RootLayout({
2425
children: React.ReactNode;
2526
}>) {
2627
return (
27-
<html lang="en">
28+
<html lang="en" suppressHydrationWarning={true}>
2829
<body
2930
suppressHydrationWarning={true}
3031
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
3132
>
32-
{children}
33-
{/* Global toaster with default settings so keyboard shortcut toasts render without being affected by per-component customizer */}
34-
<Toaster />
33+
<Providers>
34+
{children}
35+
{/* Global toaster with default settings so keyboard shortcut toasts render without being affected by per-component customizer */}
36+
<Toaster />
37+
</Providers>
3538
</body>
3639
</html>
3740
);

src/app/providers.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"use client";
2+
3+
import { ThemeProvider } from "next-themes";
4+
import React from "react";
5+
6+
/**
7+
* Client-side providers wrapper.
8+
* - Enables next-themes with system preference detection and persistent storage.
9+
* - Uses `attribute="class"` so Tailwind/Shadcn `.dark` class toggles CSS variables.
10+
*/
11+
export function Providers({ children }: { children: React.ReactNode }) {
12+
return (
13+
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
14+
{children}
15+
</ThemeProvider>
16+
);
17+
}

src/components/features/component-preview.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,7 @@ export function ComponentPreview() {
455455
<Toaster
456456
position={(customProps.position as import('sonner').ToasterProps['position']) ?? 'bottom-right'}
457457
richColors={Boolean(customProps.richColors)}
458+
duration={typeof customProps.duration === 'number' ? customProps.duration : undefined}
458459
/>
459460
<Button onClick={() => toast.success('Hello World!', { duration: typeof customProps.duration === 'number' ? customProps.duration : 4000 })}>
460461
Show Toast

src/components/features/style-controls.tsx

Lines changed: 57 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
44
import { Label } from '@/components/ui/label';
55
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
66
import { Separator } from '@/components/ui/separator';
7+
import { Button } from '@/components/ui/button';
8+
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
9+
import { ThemeToggle } from '@/components/ui/theme-toggle';
710
import { useEffect, useMemo, useState } from 'react';
811
import { useComponentStore } from '@/stores/component-store';
912

@@ -108,7 +111,7 @@ const alignOptions: Option[] = [
108111
];
109112

110113
export function StyleControls() {
111-
const { selectedComponent, stylePresetClassName, setStylePresetClassName } = useComponentStore();
114+
const { selectedComponent, setStylePresetClassName } = useComponentStore();
112115

113116
const [bgColor, setBgColor] = useState<string>('none');
114117
const [textColor, setTextColor] = useState<string>('auto');
@@ -168,6 +171,23 @@ export function StyleControls() {
168171
setStylePresetClassName('');
169172
}, [selectedComponent, setStylePresetClassName]);
170173

174+
const resetAll = () => {
175+
setBgColor('none');
176+
setTextColor('auto');
177+
setBorderStyle('none');
178+
setBorderColor('default');
179+
setPadding('none');
180+
setMargin('none');
181+
setFontSize('text-sm');
182+
setFontWeight('font-medium');
183+
setRadius('rounded-md');
184+
setHoverEffect('none');
185+
setShadow('none');
186+
setGap('none');
187+
setAlign('text-left');
188+
setStylePresetClassName('');
189+
};
190+
171191
const renderSelect = (id: string, label: string, value: string, onChange: (val: string) => void, options: Option[]) => {
172192
return (
173193
<div className="space-y-2">
@@ -193,26 +213,44 @@ export function StyleControls() {
193213
<CardDescription>Adjust visual attributes in real time</CardDescription>
194214
</CardHeader>
195215
<CardContent className="space-y-6">
196-
{renderSelect('bg-color', 'Background Color', bgColor, setBgColor, colorOptions)}
197-
<Separator className="my-4" />
198-
{renderSelect('text-color', 'Text Color', textColor, setTextColor, textColorOptions)}
199-
<Separator className="my-4" />
200-
{renderSelect('border-style', 'Border Style', borderStyle, setBorderStyle, borderStyleOptions)}
201-
{renderSelect('border-color', 'Border Color', borderColor, setBorderColor, borderColorOptions)}
216+
{/* Top actions: Theme override and Reset button */}
217+
<div className="flex items-center justify-between gap-4">
218+
<ThemeToggle />
219+
<Button variant="secondary" onClick={resetAll} aria-label="Reset styles">Reset</Button>
220+
</div>
202221
<Separator className="my-4" />
203-
{renderSelect('padding', 'Padding', padding, setPadding, paddingOptions)}
204-
{renderSelect('margin', 'Margin', margin, setMargin, marginOptions)}
205-
<Separator className="my-4" />
206-
{renderSelect('font-size', 'Font Size', fontSize, setFontSize, fontSizeOptions)}
207-
{renderSelect('font-weight', 'Font Weight', fontWeight, setFontWeight, fontWeightOptions)}
208-
<Separator className="my-4" />
209-
{renderSelect('radius', 'Border Radius', radius, setRadius, radiusOptions)}
210-
<Separator className="my-4" />
211-
{renderSelect('hover', 'Hover Effect', hoverEffect, setHoverEffect, hoverEffectOptions)}
212-
{renderSelect('shadow', 'Shadow', shadow, setShadow, shadowOptions)}
222+
223+
{/* Basic tools - prominent section */}
224+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
225+
{renderSelect('bg-color', 'Background Color', bgColor, setBgColor, colorOptions)}
226+
{renderSelect('text-color', 'Text Color', textColor, setTextColor, textColorOptions)}
227+
{renderSelect('font-size', 'Font Size', fontSize, setFontSize, fontSizeOptions)}
228+
{renderSelect('font-weight', 'Font Weight', fontWeight, setFontWeight, fontWeightOptions)}
229+
{renderSelect('radius', 'Border Radius', radius, setRadius, radiusOptions)}
230+
{renderSelect('padding', 'Padding', padding, setPadding, paddingOptions)}
231+
{renderSelect('margin', 'Margin', margin, setMargin, marginOptions)}
232+
</div>
233+
213234
<Separator className="my-4" />
214-
{renderSelect('gap', 'Component Spacing', gap, setGap, gapOptions)}
215-
{renderSelect('align', 'Alignment', align, setAlign, alignOptions)}
235+
236+
{/* Advanced options grouped under Accordion */}
237+
<Accordion type="single" collapsible>
238+
<AccordionItem value="advanced">
239+
<AccordionTrigger>
240+
<span className="font-medium">Advanced</span>
241+
</AccordionTrigger>
242+
<AccordionContent>
243+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
244+
{renderSelect('border-style', 'Border Style', borderStyle, setBorderStyle, borderStyleOptions)}
245+
{renderSelect('border-color', 'Border Color', borderColor, setBorderColor, borderColorOptions)}
246+
{renderSelect('hover', 'Hover Effect', hoverEffect, setHoverEffect, hoverEffectOptions)}
247+
{renderSelect('shadow', 'Shadow', shadow, setShadow, shadowOptions)}
248+
{renderSelect('gap', 'Component Spacing', gap, setGap, gapOptions)}
249+
{renderSelect('align', 'Alignment', align, setAlign, alignOptions)}
250+
</div>
251+
</AccordionContent>
252+
</AccordionItem>
253+
</Accordion>
216254
</CardContent>
217255
</Card>
218256
);

src/components/ui/theme-toggle.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"use client";
2+
3+
import { useTheme } from "next-themes";
4+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
5+
import { Label } from "@/components/ui/label";
6+
import React from "react";
7+
8+
/**
9+
* ThemeToggle provides a manual override for theme selection.
10+
* - Options: system, light, dark
11+
* - Persists via next-themes (localStorage) and animates smoothly per globals.css
12+
*/
13+
export function ThemeToggle() {
14+
const { theme, resolvedTheme, setTheme } = useTheme();
15+
16+
// Avoid hydration mismatch by rendering a neutral UI until mounted.
17+
const [mounted, setMounted] = React.useState(false);
18+
const [value, setValue] = React.useState<string | undefined>(undefined);
19+
React.useEffect(() => {
20+
setMounted(true);
21+
setValue(resolvedTheme);
22+
}, [resolvedTheme]);
23+
24+
return (
25+
<div className="flex items-center justify-between gap-2">
26+
<Label htmlFor="theme-select" className="text-sm">Theme</Label>
27+
<Select
28+
value={mounted ? value : undefined}
29+
onValueChange={(val) => {
30+
setValue(val);
31+
setTheme(val);
32+
}}
33+
>
34+
<SelectTrigger id="theme-select" className="w-36">
35+
{/* Use a stable placeholder before mount to prevent SSR/CSR text mismatch */}
36+
<SelectValue placeholder={mounted ? (theme ?? "system") : "Theme"} />
37+
</SelectTrigger>
38+
<SelectContent>
39+
<SelectItem value="system">System</SelectItem>
40+
<SelectItem value="light">Light</SelectItem>
41+
<SelectItem value="dark">Dark</SelectItem>
42+
</SelectContent>
43+
</Select>
44+
</div>
45+
);
46+
}

0 commit comments

Comments
 (0)