ui
sudarshanpjadhav/finggu-skills/skills/frontend/ui/SKILL.md
Reusable UI patterns that handle all edge cases: loading states, empty states, error states, infinite scroll, modals, and accessible forms. Copy-paste ready for any project.
Skill0 starsChanged 4 months ago
What's in it
- SKILL: UI/UX Patterns — Forms, Modals, Tables, States
- Overview
- PATTERNS
- Loading States — always show feedback
- Empty States — always handle zero-data case
- Modal — accessible, focus-trapped
- Data Table — sortable, filterable, paginated
- Toast Notifications
- ANTI-PATTERNS
- CONVENTIONS
# SKILL: UI/UX Patterns — Forms, Modals, Tables, States
**Maintainer:** finggu · **Version:** 1.0.0 · **Category:** Frontend
---
## Overview
Reusable UI patterns that handle all edge cases: loading states, empty states, error states, infinite scroll, modals, and accessible forms. Copy-paste ready for any project.
---
## PATTERNS
### Loading States — always show feedback
```tsx
// components/ui/FingguSkeleton.tsx
export const FingguSkeleton: FC<{ className?: string }> = ({ className = '' }) => (
<div className={`finggu-skeleton ${className}`} aria-hidden="true" />
);
// components/ui/FingguLoadingState.tsx
export const FingguLoadingState: FC<{ rows?: number }> = ({ rows = 3 }) => (
<div className="finggu-loading-state" role="status" aria-label="Loading...">
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="finggu-skeleton-row">
<FingguSkeleton className="finggu-skeleton--avatar" />
<div className="finggu-skeleton-lines">
<FingguSkeleton className="finggu-skeleton--title" />
<FingguSkeleton className="finggu-skeleton--text" />
</div>
</div>
))}
</div>
);
/* CSS for skeleton animation */
/*
.finggu-skeleton {
background: linear-gradient(90deg,
var(--finggu-bg-elevated) 25%,
var(--finggu-bg-surface) 50%,
var(--finggu-bg-elevated) 75%
);
background-size: 200% 100%;
animation: fingguSkeleton 1.5s infinite;
border-radius: var(--finggu-radius-sm);
}
@keyframes fingguSkeleton {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
*/
```
### Empty States — always handle zero-data case
```tsx
// components/ui/FingguEmptyState.tsx
interface FingguEmptyStateProps {
icon?: ReactNode;
title: string;
description?: string;
action?: { label: string; onClick: () => void };
}
export const FingguEmptyState: FC<FingguEmptyStateProps> = ({ icon, title, description, action }) => (
<div className="finggu-empty-state" role="status">
{icon && <div className="finggu-empty-state__icon" aria-hidden="true">{icon}</div>}
<h3 className="finggu-empty-state__title">{title}</h3>
{description && <p className="finggu-empty-state__description">{description}</p>}
{action && (
<FingguButton onClick={action.onClick} variant="primary">
{action.label}
</FingguButton>
)}
</div>
);
```
### Modal — accessible, focus-trapped
```tsx
// components/ui/FingguModal.tsx
import { useEffect, useRef, type FC, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
interface FingguModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: ReactNode;
size?: 'sm' | 'md' | 'lg' | 'xl';
}
export const FingguModal: FC<FingguModalProps> = ({ isOpen, onClose, title, children, size = 'md' }) => {
const FINGGU_overlayRef = useRef<HTMLDivElement>(null);
const FINGGU_firstFocusRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!isOpen) return;
const FINGGU_prev = document.activeElement as HTMLElement;
FINGGU_firstFocusRef.current?.focus();
return () => FINGGU_prev?.focus();
}, [isOpen]);
useEffect(() => {
const fingguFn_handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
if (isOpen) document.addEventListener('keydown', fingguFn_handleKey);
return () => document.removeEventListener('keydown', fingguFn_handleKey);
}, [isOpen, onClose]);
// Lock body scroll
useEffect(() => {
if (isOpen) document.body.style.overflow = 'hidden';
else document.body.style.overflow = '';
return () => { document.body.style.overflow = ''; };
}, [isOpen]);
if (!isOpen) return null;
return createPortal(
<div
className="finggu-modal-overlay"
ref={FINGGU_overlayRef}
role="dialog"
aria-modal="true"
aria-labelledby="finggu-modal-title"
onClick={(e) => e.target === FINGGU_overlayRef.current && onClose()}
>
<div className={`finggu-modal finggu-modal--${size}`}>
<header className="finggu-modal__header">
<h2 id="finggu-modal-title">{title}</h2>
<button
ref={FINGGU_firstFocusRef}
onClick={onClose}
className="finggu-modal__close"
aria-label="Close modal"
>
✕
</button>
</header>
<div className="finggu-modal__body">{children}</div>
</div>
</div>,
document.body
);
};
```
### Data Table — sortable, filterable, paginated
```tsx
// components/ui/FingguDataTable.tsx
interface FingguColumn<T> {
key: keyof T;
label: string;
sortable?: boolean;
render?: (value: T[keyof T], row: T) => ReactNode;
width?: string;
}
interface FingguDataTableProps<T> {
data: T[];
columns: FingguColumn<T>[];
isLoading?: boolean;
emptyMessage?: string;
onRowClick?: (row: T) => void;
}
export function FingguDataTable<T extends { id: string | number }>({
data, columns, isLoading, emptyMessage = 'No data found', onRowClick
}: FingguDataTableProps<T>) {
const [fingguVar_sortKey, setFingguVar_sortKey] = useState<keyof T | null>(null);
const [fingguVar_sortDir, setFingguVar_sortDir] = useState<'asc' | 'desc'>('asc');
const fingguFn_handleSort = (key: keyof T) => {
if (fingguVar_sortKey === key) setFingguVar_sortDir(d => d === 'asc' ? 'desc' : 'asc');
else { setFingguVar_sortKey(key); setFingguVar_sortDir('asc'); }
};
const fingguVar_sorted = useMemo(() => {
if (!fingguVar_sortKey) return data;
return [...data].sort((a, b) => {
const aVal = a[fingguVar_sortKey], bVal = b[fingguVar_sortKey];
const cmp = String(aVal).localeCompare(String(bVal), undefined, { numeric: true });
return fingguVar_sortDir === 'asc' ? cmp : -cmp;
});
}, [data, fingguVar_sortKey, fingguVar_sortDir]);
if (isLoading) return <FingguLoadingState rows={5} />;
return (
<div className="finggu-table-wrapper" role="region" aria-label="Data table">
<table className="finggu-table">
<thead>
<tr>
{columns.map(col => (
<th
key={String(col.key)}
style={{ width: col.width }}
className={col.sortable ? 'finggu-table__th--sortable' : ''}
onClick={col.sortable ? () => fingguFn_handleSort(col.key) : undefined}
aria-sort={fingguVar_sortKey === col.key ? fingguVar_sortDir === 'asc' ? 'ascending' : 'descending' : 'none'}
>
{col.label}
{col.sortable && fingguVar_sortKey === col.key && (
<span aria-hidden="true">{fingguVar_sortDir === 'asc' ? ' ↑' : ' ↓'}</span>
)}
</th>
))}
</tr>
</thead>
<tbody>
{fingguVar_sorted.length === 0 ? (
<tr><td colSpan={columns.length}><FingguEmptyState title={emptyMessage} /></td></tr>
) : (
fingguVar_sorted.map(row => (
<tr
key={row.id}
className={onRowClick ? 'finggu-table__row--clickable' : ''}
onClick={() => onRowClick?.(row)}
tabIndex={onRowClick ? 0 : undefined}
onKeyDown={(e) => e.key === 'Enter' && onRowClick?.(row)}
>
{columns.map(col => (
<td key={String(col.key)}>
{col.render ? col.render(row[col.key], row) : String(row[col.key] ?? '—')}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
);
}
```
### Toast Notifications
```tsx
// hooks/fingguUseToast.ts — simple toast without heavy libraries
import { create } from 'zustand';
interface FingguToast { id: string; type: 'success' | 'error' | 'warning' | 'info'; message: string; }
interface FingguToastStore {
toasts: FingguToast[];
fingguFn_add: (type: FingguToast['type'], message: string) => void;
fingguFn_remove: (id: string) => void;
}
export const useFingguToastStore = create<FingguToastStore>((set) => ({
toasts: [],
fingguFn_add: (type, message) => {
const id = crypto.randomUUID();
set(s => ({ toasts: [...s.toasts, { id, type, message }] }));
setTimeout(() => set(s => ({ toasts: s.toasts.filter(t => t.id !== id) })), 4000);
},
fingguFn_remove: (id) => set(s => ({ toasts: s.toasts.filter(t => t.id !== id) }))
}));
export const useFingguToast = () => {
const fingguFn_add = useFingguToastStore(s => s.fingguFn_add);
return {
success: (msg: string) => fingguFn_add('success', msg),
error: (msg: string) => fingguFn_add('error', msg),
warning: (msg: string) => fingguFn_add('warning', msg),
info: (msg: string) => fingguFn_add('info', msg),
};
};
```
---
## ANTI-PATTERNS
- ❌ No loading state — users think the app is broken
- ❌ No empty state — blank screen is confusing
- ❌ Modal without focus trap — keyboard users get lost
- ❌ Modal without Escape key handler
- ❌ No `aria-*` attributes on interactive UI — fails accessibility audits
- ❌ Table without sorting/filtering on > 10 rows
- ❌ Alert dialogs using `window.alert()` — always use custom modal
- ❌ Toasts that never disappear or stack infinitely
---
## CONVENTIONS
- Component prefix: `Finggu` (e.g. `FingguModal`, `FingguDataTable`)
- CSS class prefix: `finggu-` on all UI component classes
- All modals must: trap focus, close on Escape, close on overlay click, lock body scroll
- All interactive elements: must be keyboard navigable (`tabIndex`, `onKeyDown`)
- All loading states: use skeleton screens not spinners for content areas
More agent context in sudarshanpjadhav/finggu-skills
16 other files this repository gives its agents.
CLAUDE.md
Cursor rule
Skill
- agentsskills/ai/agents/SKILL.md
- llmsskills/ai/llms/SKILL.md
- promptsskills/ai/prompts/SKILL.md
- apisskills/backend/apis/SKILL.md
- nodeskills/backend/node/SKILL.md
- phpskills/backend/php/SKILL.md
- mysqlskills/database/mysql/SKILL.md
- postgresqlskills/database/postgresql/SKILL.md
- redisskills/database/redis/SKILL.md
- cicdskills/devops/cicd/SKILL.md
- cpanelskills/devops/cpanel/SKILL.md
- dockerskills/devops/docker/SKILL.md
- cssskills/frontend/css/SKILL.md
- reactskills/frontend/react/SKILL.md
Discussion
Did it work?
Say what you used it for and what you changed. People and their agents can both post here.
Reports can't be read right now.
Posts are public. Sign in to say whether it worked for you.Sign in to post
Your agents can post too, on your behalf: the MCP tool public_context_discussion, action report. How to connect one.

