UI CSS Patterns
CSS patterns for pseudo-elements, adaptive layouts, animations, and transitions in mobile/desktop app development.
Adaptive CSS without Breakpoints
Rule: container-first-responsive
Do not default to viewport breakpoints for component layout. First ask whether the UI should respond to the space available to the component, not the whole page.
Use this decision order for new CSS or CSS refactoring:
- Scalar value change: font size, spacing, padding, radius → use
clamp(),min(),max(). - Natural wrapping layout: cards, products, thumbnails, dashboard widgets → use intrinsic grid/flex patterns such as
auto-fit,auto-fill, andminmax(). - Parent/container-dependent component: same component appears in sidebar, modal, main content → use container units (
cqi,cqb,cqmin,cqmax) or@container. - Device capability or user preference: hover support, pointer type, reduced motion, dark mode, contrast, reduced data → use
@media. - Page-level major layout switch: keep viewport breakpoints if the whole page shell truly changes.
Fail: viewport breakpoints as the default layout engine
.card-grid {
display: grid;
grid-template-columns: 1fr;
}
@media (min-width: 768px) {
.card-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.card-grid {
grid-template-columns: repeat(3, 1fr);
}
}
Pass: intrinsic layout declares constraints
.card-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
}
Rule: fluid-scalars-over-breakpoint-steps
Use fluid values for continuously scalable properties instead of stepwise breakpoint overrides.
Fail:
.card {
font-size: 20px;
padding: 2rem;
}
@media (max-width: 720px) {
.card {
font-size: 18px;
padding: 1.5rem;
}
}
@media (max-width: 380px) {
.card {
font-size: 16px;
padding: 1rem;
}
}
Pass:
.card {
font-size: clamp(1rem, 2vw, 1.25rem);
padding: clamp(1rem, 4vw, 2rem);
}
Prefer tokenized fluid values in design systems:
:root {
--space-card: clamp(1rem, 4vw, 2rem);
--text-card-title: clamp(1rem, 2vw, 1.25rem);
}
Rule: container-units-for-reusable-components
When a component is reused across differently sized parents, use container units rather than viewport units.
.card-container {
container-type: inline-size;
}
.card {
font-size: clamp(1rem, 5cqi, 1.25rem);
padding: clamp(1rem, 6cqi, 2rem);
border-radius: clamp(0.25rem, 6cqi, 2rem);
}
Container units quick reference:
| Unit | Meaning |
|------|---------|
| cqi | 1% of container inline size |
| cqb | 1% of container block size |
| cqmin | 1% of smaller container axis |
| cqmax | 1% of larger container axis |
Rule: container-query-for-structure-only
Use @container when the component's internal structure changes. Do not use it for every spacing/font tweak if clamp() or container units are enough.
.media-card {
container-type: inline-size;
}
.media-card__body {
display: flex;
flex-direction: column;
gap: 1rem;
}
@container (min-width: 32rem) {
.media-card__body {
flex-direction: row;
align-items: center;
}
.media-card__description {
display: block;
}
}
Good use cases:
- Compact sidebar card vs expanded main-content card
- Modal content that should adapt to modal width
- Dashboard widgets reused across different grid columns
- Product cards that reveal supporting details only when space allows
Rule: media-query-for-capability-preference
Prefer media queries for environment and accessibility conditions rather than component layout by default.
@media (hover: hover) {
.link:hover {
text-decoration: underline;
}
}
@media (pointer: coarse) {
.icon-button {
min-width: 44px;
min-height: 44px;
}
}
@media (prefers-reduced-motion: reduce) {
.animated {
animation: none;
transition: none;
}
}
Breakpoint Refactoring Checklist
When asked to refactor responsive CSS:
- [ ] Inventory existing
@mediarules and classify each as scalar change, structural change, or environment preference. - [ ] Replace scalar breakpoint steps with
clamp(),min(),max(), or fluid tokens. - [ ] Replace fixed grid column breakpoints with
repeat(auto-fit|auto-fill, minmax(..., 1fr))where natural wrapping is acceptable. - [ ] Add
container-type: inline-sizeto reusable component containers that need local responsiveness. - [ ] Use
@containeronly for internal structure changes. - [ ] Keep
@mediafor page shell changes, hover/pointer, color scheme, reduced motion, contrast, reduced data, and other device/user preferences. - [ ] Verify the same component in main content, sidebar, modal, and narrow mobile widths when applicable.
::before & ::after Pseudo-Elements
Rule: pseudo-content-required
::before and ::after require the content property to render, even if empty.
Fail:
.element::before {
width: 20px;
height: 20px;
background: red;
}
Pass:
.element::before {
content: "";
width: 20px;
height: 20px;
background: red;
}
Rule: pseudo-over-dom-node
Use pseudo-elements for decorative content instead of adding extra DOM nodes.
Fail:
<button className="btn">
<span className="btn-bg" /> {/* extra DOM node for decoration */}
Click me
</button>
Pass:
<button className="btn">
Click me
</button>
.btn {
position: relative;
}
.btn::before {
content: "";
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.05);
border-radius: inherit;
opacity: 0;
transition: opacity 150ms ease;
}
.btn:hover::before {
opacity: 1;
}
Rule: pseudo-position-relative-parent
Parent must have position: relative for absolutely positioned pseudo-elements.
Fail:
.card::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: blue;
}
Pass:
.card {
position: relative;
}
.card::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: blue;
}
Rule: pseudo-z-index-layering
Pseudo-elements need z-index for correct layering. Use z-index: -1 to place behind content.
Fail:
.btn {
position: relative;
}
.btn::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: inherit;
/* Covers the button text */
}
Pass:
.btn {
position: relative;
isolation: isolate;
}
.btn::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: inherit;
z-index: -1;
}
isolation: isolateon the parent creates a new stacking context, ensuringz-index: -1only goes behind the parent's content, not behind the parent itself.
Rule: pseudo-hit-target-expansion
Use negative inset values on pseudo-elements to expand hit/touch targets without changing visual size.
Fail:
.icon-btn {
width: 24px;
height: 24px;
/* Touch target too small (minimum 44x44px recommended) */
}
Pass:
.icon-btn {
position: relative;
width: 24px;
height: 24px;
}
.icon-btn::before {
content: "";
position: absolute;
inset: -10px -10px;
/* Expands touch area to 44x44px without changing visual size */
}
For rectangular buttons needing wider horizontal touch targets:
.pill-btn::before {
content: "";
position: absolute;
inset: -8px -12px;
}
Button Hover Effect Pattern
A complete pattern combining the pseudo-element rules above for a polished button hover effect.
.btn-hover {
position: relative;
isolation: isolate;
overflow: hidden;
}
.btn-hover::before {
content: "";
position: absolute;
inset: 0;
background: currentColor;
opacity: 0;
border-radius: inherit;
transform: scale(0.95);
transition: opacity 150ms ease, transform 150ms ease;
z-index: -1;
}
.btn-hover:hover::before {
opacity: 0.08;
transform: scale(1);
}
.btn-hover:active::before {
opacity: 0.12;
transform: scale(0.98);
}
Tailwind CSS Equivalent
<button class="relative isolate overflow-hidden
before:content-[''] before:absolute before:inset-0
before:bg-current before:opacity-0 before:rounded-[inherit]
before:scale-95 before:transition-all before:-z-10
hover:before:opacity-[0.08] hover:before:scale-100
active:before:opacity-[0.12] active:before:scale-[0.98]">
Button Text
</button>
Separator / Divider Pattern
Use pseudo-elements for visual separators between items instead of <hr> or <div> nodes.
Fail:
<ul>
{items.map((item, i) => (
<>
<li key={item.id}>{item.name}</li>
{i < items.length - 1 && <hr className="divider" />}
</>
))}
</ul>
Pass:
<ul className="divided-list">
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
.divided-list > li + li {
position: relative;
}
.divided-list > li + li::before {
content: "";
position: absolute;
top: 0;
left: 16px;
right: 16px;
height: 1px;
background: var(--color-border, #e5e7eb);
}
Native Pseudo-Element Styling
Rule: native-backdrop-styling
Use ::backdrop for dialog and popover backgrounds instead of creating overlay div elements.
Fail:
<div className="overlay" onClick={onClose} /> {/* extra overlay div */}
<dialog open>
<p>Dialog content</p>
</dialog>
Pass:
<dialog ref={dialogRef}>
<p>Dialog content</p>
</dialog>
dialog::backdrop {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
::backdropworks with both<dialog>and Popover API elements. It is rendered by the browser automatically when the dialog is shown via.showModal().
Rule: native-placeholder-styling
Use ::placeholder for input placeholder styling.
input::placeholder {
color: #9ca3af;
font-style: italic;
}
Rule: native-selection-styling
Use ::selection for text selection styling.
::selection {
background: #3b82f6;
color: white;
}
View Transitions API
Prefer the View Transitions API over JavaScript animation libraries for page/element transitions.
Rule: transition-over-js-library
Fail:
import { motion, AnimatePresence } from "framer-motion";
function App() {
return (
<AnimatePresence mode="wait">
<motion.div
key={location.pathname}
initial={{ opacity: 0, x: 100 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -100 }}
>
<Outlet />
</motion.div>
</AnimatePresence>
);
}
Pass:
function App() {
const navigate = useNavigate();
const handleNavigate = (path: string) => {
if (!document.startViewTransition) {
navigate(path);
return;
}
document.startViewTransition(() => {
navigate(path);
});
};
return <Outlet />;
}
Framer Motion or similar libraries are still appropriate for complex in-page micro-interactions (spring physics, gesture-driven animations, layout animations). Use View Transitions API for page-level transitions and shared element transitions.
Rule: transition-name-required
Elements need view-transition-name to participate in transitions.
.hero-image {
view-transition-name: hero;
}
Rule: transition-name-unique
Names must be unique on the page during a transition. Remove from source, add to target.
Fail:
{/* Both rendered at same time with same name */}
<img style={{ viewTransitionName: "photo" }} src={thumb} />
<img style={{ viewTransitionName: "photo" }} src={full} />
Pass:
<img
style={{ viewTransitionName: selected ? undefined : `photo-${id}` }}
src={thumb}
/>
{selected && (
<img
style={{ viewTransitionName: `photo-${id}` }}
src={full}
/>
)}
Rule: transition-name-cleanup
Remove transition names after the transition completes.
const transition = document.startViewTransition(() => {
sourceEl.style.viewTransitionName = "";
targetEl.style.viewTransitionName = name;
});
transition.finished.then(() => {
targetEl.style.viewTransitionName = "";
});
Rule: transition-style-pseudo-elements
Style transitions using the generated pseudo-element tree.
::view-transition-group(hero) {
animation-duration: 300ms;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
::view-transition-old(hero) {
animation: fade-out 200ms ease-out;
}
::view-transition-new(hero) {
animation: fade-in 200ms ease-in;
}
Feature Detection
Always check for API support before using:
function safeViewTransition(callback: () => void) {
if (!document.startViewTransition) {
callback();
return;
}
document.startViewTransition(callback);
}
For detailed View Transitions patterns including lightbox, page navigation with slide direction, card-to-detail transitions, and lifecycle hooks, see references/view-transitions.md.
Rules Quick Reference
| Rule | Summary |
|------|---------|
| container-first-responsive | Prefer component/container space over viewport breakpoints for component layout |
| fluid-scalars-over-breakpoint-steps | Use clamp(), min(), max() for fluid size/spacing/radius values |
| container-units-for-reusable-components | Use cqi, cqb, cqmin, cqmax for reusable component-local sizing |
| container-query-for-structure-only | Use @container for internal structure changes, not every scalar tweak |
| media-query-for-capability-preference | Keep @media for device capability and user preference conditions |
| pseudo-content-required | ::before/::after need content property |
| pseudo-over-dom-node | Use pseudo-elements for decorative content, not extra DOM nodes |
| pseudo-position-relative-parent | Parent needs position: relative for absolute pseudo-elements |
| pseudo-z-index-layering | Use z-index: -1 with isolation: isolate on parent |
| pseudo-hit-target-expansion | Negative inset values expand touch targets |
| native-backdrop-styling | Use ::backdrop for dialog/popover backgrounds |
| native-placeholder-styling | Use ::placeholder for input placeholders |
| native-selection-styling | Use ::selection for text selection |
| transition-name-required | Elements need view-transition-name to participate |
| transition-name-unique | Names must be unique during transition |
| transition-name-cleanup | Remove names after transition completes |
| transition-over-js-library | Prefer View Transitions API for page transitions |
| transition-style-pseudo-elements | Style via ::view-transition-group/old/new pseudo-elements |
微信扫一扫