style: format all files with prettier

This commit is contained in:
Seth Hobson
2026-01-19 17:07:03 -05:00
parent 8d37048deb
commit 56848874a2
355 changed files with 15215 additions and 10241 deletions

View File

@@ -5,8 +5,8 @@
### Modal Dialog
```tsx
import { useEffect, useRef, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useEffect, useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";
interface ModalProps {
isOpen: boolean;
@@ -23,27 +23,27 @@ export function Modal({ isOpen, onClose, title, children }: ModalProps) {
if (isOpen) {
previousActiveElement.current = document.activeElement;
dialogRef.current?.focus();
document.body.style.overflow = 'hidden';
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = '';
document.body.style.overflow = "";
(previousActiveElement.current as HTMLElement)?.focus();
}
return () => {
document.body.style.overflow = '';
document.body.style.overflow = "";
};
}, [isOpen]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key === 'Tab') trapFocus(e, dialogRef.current);
if (e.key === "Escape") onClose();
if (e.key === "Tab") trapFocus(e, dialogRef.current);
};
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
document.addEventListener("keydown", handleKeyDown);
}
return () => document.removeEventListener('keydown', handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
@@ -82,7 +82,7 @@ export function Modal({ isOpen, onClose, title, children }: ModalProps) {
<div className="mt-4">{children}</div>
</div>
</div>,
document.body
document.body,
);
}
@@ -90,7 +90,7 @@ function trapFocus(e: KeyboardEvent, container: HTMLElement | null) {
if (!container) return;
const focusableElements = container.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
@@ -108,7 +108,7 @@ function trapFocus(e: KeyboardEvent, container: HTMLElement | null) {
### Dropdown Menu
```tsx
import { useState, useRef, useEffect, type ReactNode } from 'react';
import { useState, useRef, useEffect, type ReactNode } from "react";
interface DropdownProps {
trigger: ReactNode;
@@ -124,22 +124,25 @@ export function Dropdown({ trigger, children, label }: DropdownProps) {
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'Escape':
case "Escape":
setIsOpen(false);
triggerRef.current?.focus();
break;
case 'ArrowDown':
case "ArrowDown":
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
@@ -147,17 +150,17 @@ export function Dropdown({ trigger, children, label }: DropdownProps) {
focusNextItem(menuRef.current, 1);
}
break;
case 'ArrowUp':
case "ArrowUp":
e.preventDefault();
if (isOpen) {
focusNextItem(menuRef.current, -1);
}
break;
case 'Home':
case "Home":
e.preventDefault();
focusFirstItem(menuRef.current);
break;
case 'End':
case "End":
e.preventDefault();
focusLastItem(menuRef.current);
break;
@@ -177,7 +180,7 @@ export function Dropdown({ trigger, children, label }: DropdownProps) {
{trigger}
<ChevronDownIcon
aria-hidden="true"
className={`transition-transform ${isOpen ? 'rotate-180' : ''}`}
className={`transition-transform ${isOpen ? "rotate-180" : ""}`}
/>
</button>
@@ -217,18 +220,26 @@ export function MenuItem({ children, onClick, disabled }: MenuItemProps) {
function focusNextItem(menu: HTMLElement | null, direction: 1 | -1) {
if (!menu) return;
const items = menu.querySelectorAll<HTMLElement>('[role="menuitem"]:not([disabled])');
const currentIndex = Array.from(items).indexOf(document.activeElement as HTMLElement);
const items = menu.querySelectorAll<HTMLElement>(
'[role="menuitem"]:not([disabled])',
);
const currentIndex = Array.from(items).indexOf(
document.activeElement as HTMLElement,
);
const nextIndex = (currentIndex + direction + items.length) % items.length;
items[nextIndex]?.focus();
}
function focusFirstItem(menu: HTMLElement | null) {
menu?.querySelector<HTMLElement>('[role="menuitem"]:not([disabled])')?.focus();
menu
?.querySelector<HTMLElement>('[role="menuitem"]:not([disabled])')
?.focus();
}
function focusLastItem(menu: HTMLElement | null) {
const items = menu?.querySelectorAll<HTMLElement>('[role="menuitem"]:not([disabled])');
const items = menu?.querySelectorAll<HTMLElement>(
'[role="menuitem"]:not([disabled])',
);
items?.[items.length - 1]?.focus();
}
```
@@ -236,7 +247,13 @@ function focusLastItem(menu: HTMLElement | null) {
### Combobox / Autocomplete
```tsx
import { useState, useRef, useId, type ChangeEvent, type KeyboardEvent } from 'react';
import {
useState,
useRef,
useId,
type ChangeEvent,
type KeyboardEvent,
} from "react";
interface Option {
value: string;
@@ -259,7 +276,7 @@ export function Combobox({
placeholder,
}: ComboboxProps) {
const [isOpen, setIsOpen] = useState(false);
const [inputValue, setInputValue] = useState('');
const [inputValue, setInputValue] = useState("");
const [activeIndex, setActiveIndex] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
@@ -268,7 +285,7 @@ export function Combobox({
const listboxId = useId();
const filteredOptions = options.filter((option) =>
option.label.toLowerCase().includes(inputValue.toLowerCase())
option.label.toLowerCase().includes(inputValue.toLowerCase()),
);
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
@@ -286,27 +303,27 @@ export function Combobox({
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
case "ArrowDown":
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setActiveIndex((prev) =>
prev < filteredOptions.length - 1 ? prev + 1 : prev
prev < filteredOptions.length - 1 ? prev + 1 : prev,
);
}
break;
case 'ArrowUp':
case "ArrowUp":
e.preventDefault();
setActiveIndex((prev) => (prev > 0 ? prev - 1 : prev));
break;
case 'Enter':
case "Enter":
e.preventDefault();
if (activeIndex >= 0 && filteredOptions[activeIndex]) {
handleSelect(filteredOptions[activeIndex]);
}
break;
case 'Escape':
case "Escape":
setIsOpen(false);
break;
}
@@ -353,8 +370,8 @@ export function Combobox({
aria-selected={activeIndex === index}
onClick={() => handleSelect(option)}
className={`cursor-pointer px-3 py-2 ${
activeIndex === index ? 'bg-blue-100' : 'hover:bg-gray-100'
} ${value === option.value ? 'font-medium' : ''}`}
activeIndex === index ? "bg-blue-100" : "hover:bg-gray-100"
} ${value === option.value ? "font-medium" : ""}`}
>
{option.label}
</li>
@@ -375,7 +392,7 @@ export function Combobox({
### Form Validation
```tsx
import { useId, type FormEvent } from 'react';
import { useId, type FormEvent } from "react";
interface FormFieldProps {
label: string;
@@ -383,12 +400,17 @@ interface FormFieldProps {
required?: boolean;
children: (props: {
id: string;
'aria-describedby': string | undefined;
'aria-invalid': boolean;
"aria-describedby": string | undefined;
"aria-invalid": boolean;
}) => ReactNode;
}
export function FormField({ label, error, required, children }: FormFieldProps) {
export function FormField({
label,
error,
required,
children,
}: FormFieldProps) {
const id = useId();
const errorId = `${id}-error`;
@@ -405,8 +427,8 @@ export function FormField({ label, error, required, children }: FormFieldProps)
{children({
id,
'aria-describedby': error ? errorId : undefined,
'aria-invalid': !!error,
"aria-describedby": error ? errorId : undefined,
"aria-invalid": !!error,
})}
{error && (
@@ -436,13 +458,16 @@ function ContactForm() {
type="email"
required
className={`w-full rounded border px-3 py-2 ${
props['aria-invalid'] ? 'border-red-500' : 'border-gray-300'
props["aria-invalid"] ? "border-red-500" : "border-gray-300"
}`}
/>
)}
</FormField>
<button type="submit" className="mt-4 px-4 py-2 bg-blue-600 text-white rounded">
<button
type="submit"
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded"
>
Submit
</button>
</form>
@@ -476,19 +501,22 @@ export function SkipLinks() {
## Live Regions
```tsx
import { useState, useEffect } from 'react';
import { useState, useEffect } from "react";
interface LiveAnnouncerProps {
message: string;
politeness?: 'polite' | 'assertive';
politeness?: "polite" | "assertive";
}
export function LiveAnnouncer({ message, politeness = 'polite' }: LiveAnnouncerProps) {
const [announcement, setAnnouncement] = useState('');
export function LiveAnnouncer({
message,
politeness = "polite",
}: LiveAnnouncerProps) {
const [announcement, setAnnouncement] = useState("");
useEffect(() => {
// Clear first, then set - ensures screen readers pick up the change
setAnnouncement('');
setAnnouncement("");
const timer = setTimeout(() => setAnnouncement(message), 100);
return () => clearTimeout(timer);
}, [message]);
@@ -506,9 +534,15 @@ export function LiveAnnouncer({ message, politeness = 'polite' }: LiveAnnouncerP
}
// Usage in a search component
function SearchResults({ results, loading }: { results: Item[]; loading: boolean }) {
function SearchResults({
results,
loading,
}: {
results: Item[];
loading: boolean;
}) {
const message = loading
? 'Loading results...'
? "Loading results..."
: `${results.length} results found`;
return (
@@ -544,12 +578,14 @@ function useFocusTrap(containerRef: RefObject<HTMLElement>, isActive: boolean) {
if (!isActive || !containerRef.current) return;
const container = containerRef.current;
const focusableSelector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const focusableSelector =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
if (e.key !== "Tab") return;
const focusableElements = container.querySelectorAll<HTMLElement>(focusableSelector);
const focusableElements =
container.querySelectorAll<HTMLElement>(focusableSelector);
const first = focusableElements[0];
const last = focusableElements[focusableElements.length - 1];
@@ -562,8 +598,8 @@ function useFocusTrap(containerRef: RefObject<HTMLElement>, isActive: boolean) {
}
};
container.addEventListener('keydown', handleKeyDown);
return () => container.removeEventListener('keydown', handleKeyDown);
container.addEventListener("keydown", handleKeyDown);
return () => container.removeEventListener("keydown", handleKeyDown);
}, [containerRef, isActive]);
}
```
@@ -595,8 +631,12 @@ function getContrastRatio(fg: string, bg: string): number {
return (lighter + 0.05) / (darker + 0.05);
}
function meetsWCAG(fg: string, bg: string, level: 'AA' | 'AAA' = 'AA'): boolean {
function meetsWCAG(
fg: string,
bg: string,
level: "AA" | "AAA" = "AA",
): boolean {
const ratio = getContrastRatio(fg, bg);
return level === 'AAA' ? ratio >= 7 : ratio >= 4.5;
return level === "AAA" ? ratio >= 7 : ratio >= 4.5;
}
```

View File

@@ -15,7 +15,7 @@ import {
type ReactNode,
type Dispatch,
type SetStateAction,
} from 'react';
} from "react";
// Types
interface TabsContextValue {
@@ -51,7 +51,7 @@ const TabsContext = createContext<TabsContextValue | null>(null);
function useTabs() {
const context = useContext(TabsContext);
if (!context) {
throw new Error('Tabs components must be used within <Tabs>');
throw new Error("Tabs components must be used within <Tabs>");
}
return context;
}
@@ -62,11 +62,11 @@ export function Tabs({ defaultValue, children, onChange }: TabsProps) {
const handleChange: Dispatch<SetStateAction<string>> = useCallback(
(value) => {
const newValue = typeof value === 'function' ? value(activeTab) : value;
const newValue = typeof value === "function" ? value(activeTab) : value;
setActiveTab(newValue);
onChange?.(newValue);
},
[activeTab, onChange]
[activeTab, onChange],
);
return (
@@ -100,10 +100,12 @@ Tabs.Tab = function Tab({ value, children, disabled }: TabProps) {
onClick={() => setActiveTab(value)}
className={`
px-4 py-2 font-medium transition-colors
${isActive
? 'border-b-2 border-blue-600 text-blue-600'
: 'text-gray-600 hover:text-gray-900'}
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
${
isActive
? "border-b-2 border-blue-600 text-blue-600"
: "text-gray-600 hover:text-gray-900"
}
${disabled ? "opacity-50 cursor-not-allowed" : ""}
`}
>
{children}
@@ -138,7 +140,9 @@ Tabs.Panel = function TabPanel({ value, children }: TabPanelProps) {
<Tabs.List>
<Tabs.Tab value="overview">Overview</Tabs.Tab>
<Tabs.Tab value="features">Features</Tabs.Tab>
<Tabs.Tab value="pricing" disabled>Pricing</Tabs.Tab>
<Tabs.Tab value="pricing" disabled>
Pricing
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="overview">
<h2>Product Overview</h2>
@@ -180,14 +184,14 @@ function DataLoader<T>({ url, children }: DataLoaderProps<T>) {
});
const fetchData = useCallback(async () => {
setState(prev => ({ ...prev, loading: true, error: null }));
setState((prev) => ({ ...prev, loading: true, error: null }));
try {
const response = await fetch(url);
if (!response.ok) throw new Error('Fetch failed');
if (!response.ok) throw new Error("Fetch failed");
const data = await response.json();
setState({ data, loading: false, error: null });
} catch (error) {
setState(prev => ({ ...prev, loading: false, error: error as Error }));
setState((prev) => ({ ...prev, loading: false, error: error as Error }));
}
}, [url]);
@@ -205,7 +209,7 @@ function DataLoader<T>({ url, children }: DataLoaderProps<T>) {
if (error) return <ErrorMessage error={error} onRetry={refetch} />;
return <UserList users={data!} />;
}}
</DataLoader>
</DataLoader>;
```
## Polymorphic Components
@@ -343,18 +347,10 @@ function Card({ children, header, footer, media }: CardProps) {
return (
<article className="rounded-lg border bg-white shadow-sm">
{media && (
<div className="aspect-video overflow-hidden rounded-t-lg">
{media}
</div>
<div className="aspect-video overflow-hidden rounded-t-lg">{media}</div>
)}
{header && (
<header className="border-b px-4 py-3">
{header}
</header>
)}
<div className="px-4 py-4">
{children}
</div>
{header && <header className="border-b px-4 py-3">{header}</header>}
<div className="px-4 py-4">{children}</div>
{footer && (
<footer className="border-t px-4 py-3 bg-gray-50 rounded-b-lg">
{footer}
@@ -371,7 +367,7 @@ function Card({ children, header, footer, media }: CardProps) {
footer={<Button>Action</Button>}
>
<p>Card content goes here.</p>
</Card>
</Card>;
```
## Forward Ref Pattern
@@ -379,7 +375,7 @@ function Card({ children, header, footer, media }: CardProps) {
Allow parent components to access the underlying DOM node.
```tsx
import { forwardRef, useRef, useImperativeHandle } from 'react';
import { forwardRef, useRef, useImperativeHandle } from "react";
interface InputHandle {
focus: () => void;
@@ -399,9 +395,9 @@ const FancyInput = forwardRef<InputHandle, FancyInputProps>(
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
clear: () => {
if (inputRef.current) inputRef.current.value = '';
if (inputRef.current) inputRef.current.value = "";
},
getValue: () => inputRef.current?.value ?? '',
getValue: () => inputRef.current?.value ?? "",
}));
return (
@@ -415,10 +411,10 @@ const FancyInput = forwardRef<InputHandle, FancyInputProps>(
/>
</div>
);
}
},
);
FancyInput.displayName = 'FancyInput';
FancyInput.displayName = "FancyInput";
// Usage
function Form() {

View File

@@ -2,13 +2,13 @@
## Comparison Matrix
| Approach | Runtime | Bundle Size | Learning Curve | Dynamic Styles | SSR |
|----------|---------|-------------|----------------|----------------|-----|
| CSS Modules | None | Minimal | Low | Limited | Yes |
| Tailwind | None | Small (purged) | Medium | Via classes | Yes |
| styled-components | Yes | Medium | Medium | Full | Yes* |
| Emotion | Yes | Medium | Medium | Full | Yes |
| Vanilla Extract | None | Minimal | High | Limited | Yes |
| Approach | Runtime | Bundle Size | Learning Curve | Dynamic Styles | SSR |
| ----------------- | ------- | -------------- | -------------- | -------------- | ----- |
| CSS Modules | None | Minimal | Low | Limited | Yes |
| Tailwind | None | Small (purged) | Medium | Via classes | Yes |
| styled-components | Yes | Medium | Medium | Full | Yes\* |
| Emotion | Yes | Medium | Medium | Full | Yes |
| Vanilla Extract | None | Minimal | High | Limited | Yes |
## CSS Modules
@@ -56,19 +56,19 @@ Scoped CSS with zero runtime overhead.
```tsx
// Button.tsx
import styles from './Button.module.css';
import { clsx } from 'clsx';
import styles from "./Button.module.css";
import { clsx } from "clsx";
interface ButtonProps {
variant?: 'primary' | 'secondary';
size?: 'small' | 'medium' | 'large';
variant?: "primary" | "secondary";
size?: "small" | "medium" | "large";
children: React.ReactNode;
onClick?: () => void;
}
export function Button({
variant = 'primary',
size = 'medium',
variant = "primary",
size = "medium",
children,
onClick,
}: ButtonProps) {
@@ -77,7 +77,7 @@ export function Button({
className={clsx(
styles.button,
styles[variant],
size !== 'medium' && styles[size]
size !== "medium" && styles[size],
)}
onClick={onClick}
>
@@ -104,7 +104,7 @@ export function Button({
/* Button.module.css */
.srOnly {
composes: visuallyHidden from './base.module.css';
composes: visuallyHidden from "./base.module.css";
}
```
@@ -115,38 +115,42 @@ Utility-first CSS with design system constraints.
### Class Variance Authority (CVA)
```tsx
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
// Base styles
'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: 'default',
size: 'default',
variant: "default",
size: "default",
},
}
},
);
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
@@ -160,7 +164,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
{...props}
/>
);
}
},
);
```
@@ -168,48 +172,48 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
```tsx
// lib/utils.ts
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// Usage - handles conflicting classes
cn('px-4 py-2', 'px-6'); // => 'py-2 px-6'
cn('text-red-500', condition && 'text-blue-500'); // => 'text-blue-500' if condition
cn("px-4 py-2", "px-6"); // => 'py-2 px-6'
cn("text-red-500", condition && "text-blue-500"); // => 'text-blue-500' if condition
```
### Custom Plugin
```js
// tailwind.config.js
const plugin = require('tailwindcss/plugin');
const plugin = require("tailwindcss/plugin");
module.exports = {
plugins: [
plugin(function({ addUtilities, addComponents, theme }) {
plugin(function ({ addUtilities, addComponents, theme }) {
// Add utilities
addUtilities({
'.text-balance': {
'text-wrap': 'balance',
".text-balance": {
"text-wrap": "balance",
},
'.scrollbar-hide': {
'-ms-overflow-style': 'none',
'scrollbar-width': 'none',
'&::-webkit-scrollbar': {
display: 'none',
".scrollbar-hide": {
"-ms-overflow-style": "none",
"scrollbar-width": "none",
"&::-webkit-scrollbar": {
display: "none",
},
},
});
// Add components
addComponents({
'.card': {
backgroundColor: theme('colors.white'),
borderRadius: theme('borderRadius.lg'),
padding: theme('spacing.6'),
boxShadow: theme('boxShadow.md'),
".card": {
backgroundColor: theme("colors.white"),
borderRadius: theme("borderRadius.lg"),
padding: theme("spacing.6"),
boxShadow: theme("boxShadow.md"),
},
});
}),
@@ -222,7 +226,7 @@ module.exports = {
CSS-in-JS with template literals.
```tsx
import styled, { css, keyframes } from 'styled-components';
import styled, { css, keyframes } from "styled-components";
// Keyframes
const fadeIn = keyframes`
@@ -232,8 +236,8 @@ const fadeIn = keyframes`
// Base button with variants
interface ButtonProps {
$variant?: 'primary' | 'secondary' | 'ghost';
$size?: 'sm' | 'md' | 'lg';
$variant?: "primary" | "secondary" | "ghost";
$size?: "sm" | "md" | "lg";
$isLoading?: boolean;
}
@@ -287,8 +291,8 @@ const Button = styled.button<ButtonProps>`
transition: all 0.2s ease;
animation: ${fadeIn} 0.3s ease;
${({ $size = 'md' }) => sizeStyles[$size]}
${({ $variant = 'primary' }) => variantStyles[$variant]}
${({ $size = "md" }) => sizeStyles[$size]}
${({ $variant = "primary" }) => variantStyles[$variant]}
&:disabled {
opacity: 0.5;
@@ -312,12 +316,12 @@ const IconButton = styled(Button)`
// Theme provider
const theme = {
colors: {
primary: '#2563eb',
primaryHover: '#1d4ed8',
secondary: '#f3f4f6',
secondaryHover: '#e5e7eb',
ghost: 'rgba(0, 0, 0, 0.05)',
text: '#1f2937',
primary: "#2563eb",
primaryHover: "#1d4ed8",
secondary: "#f3f4f6",
secondaryHover: "#e5e7eb",
ghost: "rgba(0, 0, 0, 0.05)",
text: "#1f2937",
},
};
@@ -326,7 +330,7 @@ const theme = {
<Button $variant="primary" $size="lg">
Click me
</Button>
</ThemeProvider>
</ThemeProvider>;
```
## Emotion
@@ -335,11 +339,11 @@ Flexible CSS-in-JS with object and template syntax.
```tsx
/** @jsxImportSource @emotion/react */
import { css, Theme, ThemeProvider, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { css, Theme, ThemeProvider, useTheme } from "@emotion/react";
import styled from "@emotion/styled";
// Theme typing
declare module '@emotion/react' {
declare module "@emotion/react" {
export interface Theme {
colors: {
primary: string;
@@ -352,20 +356,21 @@ declare module '@emotion/react' {
const theme: Theme = {
colors: {
primary: '#2563eb',
background: '#ffffff',
text: '#1f2937',
primary: "#2563eb",
background: "#ffffff",
text: "#1f2937",
},
spacing: (factor: number) => `${factor * 0.25}rem`,
};
// Object syntax
const cardStyles = (theme: Theme) => css({
backgroundColor: theme.colors.background,
padding: theme.spacing(4),
borderRadius: '0.5rem',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
});
const cardStyles = (theme: Theme) =>
css({
backgroundColor: theme.colors.background,
padding: theme.spacing(4),
borderRadius: "0.5rem",
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.1)",
});
// Template literal syntax
const buttonStyles = css`
@@ -407,7 +412,7 @@ function Alert({ children }: { children: React.ReactNode }) {
<Card>
<Alert>Important message</Alert>
</Card>
</ThemeProvider>
</ThemeProvider>;
```
## Vanilla Extract
@@ -416,26 +421,26 @@ Zero-runtime CSS-in-JS with full type safety.
```tsx
// styles.css.ts
import { style, styleVariants, createTheme } from '@vanilla-extract/css';
import { recipe, type RecipeVariants } from '@vanilla-extract/recipes';
import { style, styleVariants, createTheme } from "@vanilla-extract/css";
import { recipe, type RecipeVariants } from "@vanilla-extract/recipes";
// Theme contract
export const [themeClass, vars] = createTheme({
color: {
primary: '#2563eb',
secondary: '#64748b',
background: '#ffffff',
text: '#1f2937',
primary: "#2563eb",
secondary: "#64748b",
background: "#ffffff",
text: "#1f2937",
},
space: {
small: '0.5rem',
medium: '1rem',
large: '1.5rem',
small: "0.5rem",
medium: "1rem",
large: "1.5rem",
},
radius: {
small: '0.25rem',
medium: '0.375rem',
large: '0.5rem',
small: "0.25rem",
medium: "0.375rem",
large: "0.5rem",
},
});
@@ -455,54 +460,54 @@ export const text = styleVariants({
// Recipe (like CVA)
export const button = recipe({
base: {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
fontWeight: 500,
borderRadius: vars.radius.medium,
transition: 'background-color 0.2s',
cursor: 'pointer',
border: 'none',
':disabled': {
transition: "background-color 0.2s",
cursor: "pointer",
border: "none",
":disabled": {
opacity: 0.5,
cursor: 'not-allowed',
cursor: "not-allowed",
},
},
variants: {
variant: {
primary: {
backgroundColor: vars.color.primary,
color: 'white',
':hover': {
backgroundColor: '#1d4ed8',
color: "white",
":hover": {
backgroundColor: "#1d4ed8",
},
},
secondary: {
backgroundColor: '#f3f4f6',
backgroundColor: "#f3f4f6",
color: vars.color.text,
':hover': {
backgroundColor: '#e5e7eb',
":hover": {
backgroundColor: "#e5e7eb",
},
},
},
size: {
small: {
padding: '0.25rem 0.75rem',
fontSize: '0.875rem',
padding: "0.25rem 0.75rem",
fontSize: "0.875rem",
},
medium: {
padding: '0.5rem 1rem',
fontSize: '1rem',
padding: "0.5rem 1rem",
fontSize: "1rem",
},
large: {
padding: '0.75rem 1.5rem',
fontSize: '1.125rem',
padding: "0.75rem 1.5rem",
fontSize: "1.125rem",
},
},
},
defaultVariants: {
variant: 'primary',
size: 'medium',
variant: "primary",
size: "medium",
},
});
@@ -511,7 +516,7 @@ export type ButtonVariants = RecipeVariants<typeof button>;
```tsx
// Button.tsx
import { button, type ButtonVariants, themeClass } from './styles.css';
import { button, type ButtonVariants, themeClass } from "./styles.css";
interface ButtonProps extends ButtonVariants {
children: React.ReactNode;
@@ -545,8 +550,8 @@ function App() {
```tsx
// Next.js with styled-components
// pages/_document.tsx
import Document, { DocumentContext } from 'next/document';
import { ServerStyleSheet } from 'styled-components';
import Document, { DocumentContext } from "next/document";
import { ServerStyleSheet } from "styled-components";
export default class MyDocument extends Document {
static async getInitialProps(ctx: DocumentContext) {
@@ -576,9 +581,9 @@ export default class MyDocument extends Document {
```tsx
// Dynamically import heavy styled components
import dynamic from 'next/dynamic';
import dynamic from "next/dynamic";
const HeavyChart = dynamic(() => import('./HeavyChart'), {
const HeavyChart = dynamic(() => import("./HeavyChart"), {
loading: () => <Skeleton height={400} />,
ssr: false,
});