Grid / List View Switcher
A grid / list toggle where the items themselves morph between layouts using FLIP, instead of re-rendering in place.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/view-switcher.json'use client'
import { useLayoutEffect, useRef, useState } from 'react'
import './view-switcher.css'
export interface ViewItem { id: string; title: string; meta: string }
export interface ViewSwitcherProps {
items: ViewItem[]
defaultView?: 'grid' | 'list'
onViewChange?: (v: 'grid' | 'list') => void
fill?: 'dark' | 'light'
className?: string
}
/** Grid/list toggle with FLIP-animated items. */
export function ViewSwitcher({ items, defaultView = 'grid', onViewChange, fill = 'dark', className }: ViewSwitcherProps) {
const [view, setView] = useState(defaultView)
const refs = useRef<Record<string, HTMLLIElement | null>>({})
const before = useRef<Record<string, DOMRect>>({})
const switchTo = (v: 'grid' | 'list') => {
if (v === view) return
for (const it of items) { const el = refs.current[it.id]; if (el) before.current[it.id] = el.getBoundingClientRect() }
setView(v); onViewChange?.(v)
}
useLayoutEffect(() => {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) { before.current = {}; return }
for (const it of items) {
const el = refs.current[it.id], a = before.current[it.id]
if (!el || !a) continue
const b = el.getBoundingClientRect()
el.animate([
{ transform: `translate(${a.left - b.left}px, ${a.top - b.top}px) scale(${a.width / b.width}, ${a.height / b.height})`, transformOrigin: 'top left' },
{ transform: 'none', transformOrigin: 'top left' },
], { duration: 420, easing: 'cubic-bezier(0.3, 1.1, 0.4, 1)' })
}
before.current = {}
}, [view, items])
const icon = (v: 'grid' | 'list') => v === 'grid'
? <svg viewBox="0 0 15 15" aria-hidden><rect x="2" y="2" width="4.5" height="4.5" rx="1" /><rect x="8.5" y="2" width="4.5" height="4.5" rx="1" /><rect x="2" y="8.5" width="4.5" height="4.5" rx="1" /><rect x="8.5" y="8.5" width="4.5" height="4.5" rx="1" /></svg>
: <svg viewBox="0 0 15 15" aria-hidden><rect x="2" y="2.5" width="11" height="3" rx="1" /><rect x="2" y="9.5" width="11" height="3" rx="1" /></svg>
return (
<div className={['sd-vsw', className].filter(Boolean).join(' ')} data-fill={fill} data-view={view}>
<div className="sd-vsw__top">
<span>{items.length} files</span>
<div className="sd-vsw__tg" role="radiogroup" aria-label="View">
{(['grid', 'list'] as const).map((v) => <button key={v} type="button" role="radio" aria-checked={view === v} aria-label={`${v[0].toUpperCase() + v.slice(1)} view`} onClick={() => switchTo(v)}>{icon(v)}</button>)}
</div>
</div>
<ul className="sd-vsw__list">
{items.map((it) => (
<li key={it.id} ref={(el) => { refs.current[it.id] = el }} className="sd-vsw__it">
<span className="sd-vsw__th" aria-hidden>{it.title.split('.').pop()?.toUpperCase()}</span>
<span style={{ minWidth: 0 }}><span className="sd-vsw__t" style={{ display: 'block' }}>{it.title}</span><span className="sd-vsw__m">{it.meta}</span></span>
</li>
))}
</ul>
</div>
)
}.sd-vsw { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-vsw[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-vsw button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-vsw :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-vsw .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-vsw { width: min(100%, 330px); padding: 12px; border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-vsw__top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; font-size: 13px; color: var(--mute); }
.sd-vsw__tg { display: inline-flex; padding: 3px; border-radius: 10px; background: var(--soft); }
.sd-vsw__tg button { display: grid; place-items: center; width: 32px; height: 28px; border-radius: 7px; color: var(--mute); }
.sd-vsw__tg button[aria-checked='true'] { background: var(--bg) !important; color: var(--fg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-vsw__tg svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linejoin: round; }
.sd-vsw__list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
.sd-vsw[data-view='grid'] .sd-vsw__list { grid-template-columns: 1fr 1fr; }
.sd-vsw__it { display: flex; gap: 10px; align-items: center; padding: 8px; border-radius: 12px; box-shadow: inset 0 0 0 1px var(--line); background: var(--bg); will-change: transform; overflow: hidden; }
.sd-vsw[data-view='grid'] .sd-vsw__it { flex-direction: column; align-items: stretch; }
.sd-vsw__th { flex: none; width: 40px; height: 40px; border-radius: 8px; background: var(--soft); display: grid; place-items: center; font-weight: 650; color: var(--mute); }
.sd-vsw[data-view='grid'] .sd-vsw__th { width: 100%; height: 64px; }
.sd-vsw__t { font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sd-vsw__m { font-size: 11.5px; color: var(--mute); }
@media (prefers-reduced-motion: reduce) { .sd-vsw *, .sd-vsw *::before, .sd-vsw *::after { animation: none !important; transition: none !important; } }Overview
Each item measures where it was, the layout switches, and it animates from the old box to the new one, so cards visibly reflow into rows and back.
The toggle is a two-option segmented control with icons; the choice can be persisted by the caller.
When to use
- File browsers, galleries, product listings.
When not to use
- Data tables: keep one layout.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/view-switcher.json$
npx shadcn@latest add https://sundaeui.dev/r/view-switcher.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/view-switcher.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/view-switcher.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy view-switcher.tsx and view-switcher.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { ViewSwitcher } from '@/components/sundae/view-switcher'
<ViewSwitcher />Props
| Prop | Type | Default |
|---|---|---|
items*Items. | { id: string; title: string; meta: string }[] | — |
view / defaultViewLayout. | 'grid' | 'list' | 'grid' |
onViewChangeCalled on change. | (v) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The toggle is a radiogroup with labelled icon buttons; the list is a list in both layouts.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-vsw; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Grid / List View Switcher free?
- Yes. Grid / List View Switcher is free for personal and commercial projects, with full source on this page.
- How do I install Grid / List View Switcher?
- Run npx shadcn@latest add https://sundaeui.dev/r/view-switcher.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Grid / List View Switcher support keyboard and screen readers?
- The toggle is a radiogroup with labelled icon buttons; the list is a list in both layouts. Reduced motion removes the movement and keeps every state change.
- Does Grid / List View Switcher respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Alarm List
Components · Refined- Pro
Swipe Actions
Components · Refined Follow Button
Components · RefinedBookmark Button
Components · Refined- Pro
Sortable List
Components · Refined Theme Toggle
Components · Refined