iOS List View
A re-creation of the iOS List View built with React Aria Components, Framer Motion, and Tailwind CSS with support for swipe gestures, layout animations, and multiple selection mode.
Example#
import {Button, GridList, Item} from 'react-aria-components';
import type {Selection, SelectionMode} from 'react-aria-components';
import {animate, AnimatePresence, motion, useIsPresent, useMotionTemplate, useMotionValue, useMotionValueEvent} from 'framer-motion';
import {useRef, useState} from 'react';
import type {CSSProperties} from 'react';
const MotionItem = motion(Item);
const inertiaTransition = {
type: 'inertia' as const,
bounceStiffness: 300,
bounceDamping: 40,
timeConstant: 300
};
function SwipableList() {
let [items, setItems] = useState(messages.emails);
let [selectedKeys, setSelectedKeys] = useState<Selection>(new Set());
let [selectionMode, setSelectionMode] = useState<SelectionMode>('none');
let onDelete = () => {
setItems(
items.filter((i) => selectedKeys !== 'all' && !selectedKeys.has(i.id))
);
setSelectedKeys(new Set());
setSelectionMode('none');
};
return (
<div className="flex flex-col h-full max-h-[500px] sm:w-[400px] -mx-[14px] sm:mx-0">
{/* Toolbar */}
<div className="flex pb-4 justify-between">
<Button
className="text-blue-600 text-lg outline-none bg-transparent border-none transition pressed:text-blue-700 focus-visible:ring disabled:text-gray-400"
style={{ opacity: selectionMode === 'none' ? 0 : 1 }}
isDisabled={selectedKeys !== 'all' && selectedKeys.size === 0}
onPress={onDelete}
>
Delete
</Button>
<Button
className="text-blue-600 text-lg outline-none bg-transparent border-none transition pressed:text-blue-700 focus-visible:ring"
onPress={() => {
setSelectionMode((m) => (m === 'none' ? 'multiple' : 'none'));
setSelectedKeys(new Set());
}}
>
{selectionMode === 'none' ? 'Edit' : 'Cancel'}
</Button>
</div>
<GridList
className="relative flex-1 overflow-auto"
aria-label="Inbox"
onAction={selectionMode === 'none' ? () => {} : undefined}
selectionMode={selectionMode}
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
>
<AnimatePresence>
{items.map((item) => (
<ListItem
key={item.id}
id={item.id}
textValue={[item.sender, item.date, item.subject, item.message]
.join('\n')}
onRemove={() => setItems(items.filter((i) => i !== item))}
>
<div className="flex flex-col text-md cursor-default">
<div className="flex justify-between">
<p className="font-bold text-lg m-0">{item.sender}</p>
<p className="text-gray-500 m-0">{item.date}</p>
</div>
<p className="m-0">{item.subject}</p>
<p className="line-clamp-2 text-gray-500 dark:text-gray-400 m-0">
{item.message}
</p>
</div>
</ListItem>
))}
</AnimatePresence>
</GridList>
</div>
);
}
function ListItem({ id, children, textValue, onRemove }) {
let ref = useRef(null);
let x = useMotionValue(0);
let isPresent = useIsPresent();
let xPx = useMotionTemplate` px`;
// Align the text in the remove button to the left if the
// user has swiped at least 80% of the width.
let [align, setAlign] = useState('end');
useMotionValueEvent(x, 'change', (x) => {
let a = x < -ref.current?.offsetWidth * 0.8 ? 'start' : 'end';
setAlign(a);
});
return (
<MotionItem
id={id}
textValue={textValue}
className="outline-none group relative overflow-clip border-t border-0 border-solid last:border-b border-gray-200 dark:border-gray-800 pressed:bg-gray-200 dark:pressed:bg-gray-800 selected:bg-gray-200 dark:selected:bg-gray-800 focus-visible:outline focus-visible:outline-blue-600 focus-visible:-outline-offset-2"
layout
transition={{ duration: 0.25 }}
exit={{ opacity: 0 }}
// Take item out of the flow if it is being removed.
style={{ position: isPresent ? 'relative' : 'absolute' }}
>
{/* @ts-ignore - Framer Motion's types don't handle functions properly. */}
{({ selectionMode, isSelected }) => (
// Content of the item can be swiped to reveal the delete button, or fully swiped to delete.
<motion.div
ref={ref}
style={{ x, '--x': xPx } as CSSProperties}
className="flex items-center"
drag={selectionMode === 'none' ? 'x' : undefined}
dragConstraints={{ right: 0 }}
onDragEnd={(e, { offset }) => {
// If the user dragged past 80% of the width, remove the item
// otherwise animate back to the nearest snap point.
let v = offset.x > -20 ? 0 : -100;
if (x.get() < -ref.current.offsetWidth * 0.8) {
v = -ref.current.offsetWidth;
onRemove();
}
animate(x, v, { ...inertiaTransition, min: v, max: v });
}}
onDragStart={() => {
// Cancel react-aria press event when dragging starts.
document.dispatchEvent(new PointerEvent('pointercancel'));
}}
>
{selectionMode === 'multiple' && (
<SelectionCheckmark isSelected={isSelected} />
)}
<motion.div
layout
layoutDependency={selectionMode}
transition={{ duration: 0.25 }}
className="relative flex items-center px-4 py-2 z-10"
>
{children}
</motion.div>
{selectionMode === 'none' && (
<Button
className="bg-red-600 pressed:bg-red-700 cursor-default text-lg outline-none border-none transition-colors text-white flex items-center absolute top-0 left-[100%] py-2 h-full z-0 isolate focus-visible:outline focus-visible:outline-blue-600 focus-visible:-outline-offset-2"
style={{
// Calculate the size of the button based on the drag position,
// which is stored in a CSS variable above.
width: 'max(100px, calc(-1 * var(--x)))',
justifyContent: align
}}
onPress={onRemove}
// Move the button into view when it is focused with the keyboard
// (e.g. via the arrow keys).
onFocus={() => x.set(-100)}
onBlur={() => x.set(0)}
>
<motion.span
initial={false}
className="px-4"
animate={{
// Whenever the alignment changes, perform a keyframe animation
// between the previous position and new position. This is done
// by calculating a transform for the previous alignment and
// animating it back to zero.
transform: align === 'start'
? ['translateX(calc(-100% - var(--x)))', 'translateX(0)']
: ['translateX(calc(100% + var(--x)))', 'translateX(0)']
}}
>
Delete
</motion.span>
</Button>
)}
</motion.div>
)}
</MotionItem>
);
}
function SelectionCheckmark({ isSelected }) {
return (
<motion.svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="currentColor"
className="w-6 h-6 flex-shrink-0 ml-4"
initial={{ x: -40 }}
animate={{ x: 0 }}
transition={{ duration: 0.25 }}
>
{!isSelected && (
<circle
r={9}
cx={12}
cy={12}
stroke="currentColor"
fill="none"
strokeWidth={1}
className="text-gray-400"
/>
)}
{isSelected && (
<path
className="text-blue-600"
fillRule="evenodd"
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm13.36-1.814a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z"
clipRule="evenodd"
/>
)}
</motion.svg>
);
}
import {
Button,
GridList,
Item
} from 'react-aria-components';
import type {
Selection,
SelectionMode
} from 'react-aria-components';
import {
animate,
AnimatePresence,
motion,
useIsPresent,
useMotionTemplate,
useMotionValue,
useMotionValueEvent
} from 'framer-motion';
import {useRef, useState} from 'react';
import type {CSSProperties} from 'react';
const MotionItem = motion(Item);
const inertiaTransition = {
type: 'inertia' as const,
bounceStiffness: 300,
bounceDamping: 40,
timeConstant: 300
};
function SwipableList() {
let [items, setItems] = useState(messages.emails);
let [selectedKeys, setSelectedKeys] = useState<Selection>(
new Set()
);
let [selectionMode, setSelectionMode] = useState<
SelectionMode
>('none');
let onDelete = () => {
setItems(
items.filter((i) =>
selectedKeys !== 'all' && !selectedKeys.has(i.id)
)
);
setSelectedKeys(new Set());
setSelectionMode('none');
};
return (
<div className="flex flex-col h-full max-h-[500px] sm:w-[400px] -mx-[14px] sm:mx-0">
{/* Toolbar */}
<div className="flex pb-4 justify-between">
<Button
className="text-blue-600 text-lg outline-none bg-transparent border-none transition pressed:text-blue-700 focus-visible:ring disabled:text-gray-400"
style={{
opacity: selectionMode === 'none' ? 0 : 1
}}
isDisabled={selectedKeys !== 'all' &&
selectedKeys.size === 0}
onPress={onDelete}
>
Delete
</Button>
<Button
className="text-blue-600 text-lg outline-none bg-transparent border-none transition pressed:text-blue-700 focus-visible:ring"
onPress={() => {
setSelectionMode((
m
) => (m === 'none' ? 'multiple' : 'none'));
setSelectedKeys(new Set());
}}
>
{selectionMode === 'none' ? 'Edit' : 'Cancel'}
</Button>
</div>
<GridList
className="relative flex-1 overflow-auto"
aria-label="Inbox"
onAction={selectionMode === 'none'
? () => {}
: undefined}
selectionMode={selectionMode}
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
>
<AnimatePresence>
{items.map((item) => (
<ListItem
key={item.id}
id={item.id}
textValue={[
item.sender,
item.date,
item.subject,
item.message
].join('\n')}
onRemove={() =>
setItems(items.filter((i) => i !== item))}
>
<div className="flex flex-col text-md cursor-default">
<div className="flex justify-between">
<p className="font-bold text-lg m-0">
{item.sender}
</p>
<p className="text-gray-500 m-0">
{item.date}
</p>
</div>
<p className="m-0">{item.subject}</p>
<p className="line-clamp-2 text-gray-500 dark:text-gray-400 m-0">
{item.message}
</p>
</div>
</ListItem>
))}
</AnimatePresence>
</GridList>
</div>
);
}
function ListItem({ id, children, textValue, onRemove }) {
let ref = useRef(null);
let x = useMotionValue(0);
let isPresent = useIsPresent();
let xPx = useMotionTemplate` px`;
// Align the text in the remove button to the left if the
// user has swiped at least 80% of the width.
let [align, setAlign] = useState('end');
useMotionValueEvent(x, 'change', (x) => {
let a = x < -ref.current?.offsetWidth * 0.8
? 'start'
: 'end';
setAlign(a);
});
return (
<MotionItem
id={id}
textValue={textValue}
className="outline-none group relative overflow-clip border-t border-0 border-solid last:border-b border-gray-200 dark:border-gray-800 pressed:bg-gray-200 dark:pressed:bg-gray-800 selected:bg-gray-200 dark:selected:bg-gray-800 focus-visible:outline focus-visible:outline-blue-600 focus-visible:-outline-offset-2"
layout
transition={{ duration: 0.25 }}
exit={{ opacity: 0 }}
// Take item out of the flow if it is being removed.
style={{
position: isPresent ? 'relative' : 'absolute'
}}
>
{/* @ts-ignore - Framer Motion's types don't handle functions properly. */}
{({ selectionMode, isSelected }) => (
// Content of the item can be swiped to reveal the delete button, or fully swiped to delete.
<motion.div
ref={ref}
style={{ x, '--x': xPx } as CSSProperties}
className="flex items-center"
drag={selectionMode === 'none' ? 'x' : undefined}
dragConstraints={{ right: 0 }}
onDragEnd={(e, { offset }) => {
// If the user dragged past 80% of the width, remove the item
// otherwise animate back to the nearest snap point.
let v = offset.x > -20 ? 0 : -100;
if (x.get() < -ref.current.offsetWidth * 0.8) {
v = -ref.current.offsetWidth;
onRemove();
}
animate(x, v, {
...inertiaTransition,
min: v,
max: v
});
}}
onDragStart={() => {
// Cancel react-aria press event when dragging starts.
document.dispatchEvent(
new PointerEvent('pointercancel')
);
}}
>
{selectionMode === 'multiple' && (
<SelectionCheckmark isSelected={isSelected} />
)}
<motion.div
layout
layoutDependency={selectionMode}
transition={{ duration: 0.25 }}
className="relative flex items-center px-4 py-2 z-10"
>
{children}
</motion.div>
{selectionMode === 'none' && (
<Button
className="bg-red-600 pressed:bg-red-700 cursor-default text-lg outline-none border-none transition-colors text-white flex items-center absolute top-0 left-[100%] py-2 h-full z-0 isolate focus-visible:outline focus-visible:outline-blue-600 focus-visible:-outline-offset-2"
style={{
// Calculate the size of the button based on the drag position,
// which is stored in a CSS variable above.
width: 'max(100px, calc(-1 * var(--x)))',
justifyContent: align
}}
onPress={onRemove}
// Move the button into view when it is focused with the keyboard
// (e.g. via the arrow keys).
onFocus={() => x.set(-100)}
onBlur={() => x.set(0)}
>
<motion.span
initial={false}
className="px-4"
animate={{
// Whenever the alignment changes, perform a keyframe animation
// between the previous position and new position. This is done
// by calculating a transform for the previous alignment and
// animating it back to zero.
transform: align === 'start'
? [
'translateX(calc(-100% - var(--x)))',
'translateX(0)'
]
: [
'translateX(calc(100% + var(--x)))',
'translateX(0)'
]
}}
>
Delete
</motion.span>
</Button>
)}
</motion.div>
)}
</MotionItem>
);
}
function SelectionCheckmark({ isSelected }) {
return (
<motion.svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="currentColor"
className="w-6 h-6 flex-shrink-0 ml-4"
initial={{ x: -40 }}
animate={{ x: 0 }}
transition={{ duration: 0.25 }}
>
{!isSelected && (
<circle
r={9}
cx={12}
cy={12}
stroke="currentColor"
fill="none"
strokeWidth={1}
className="text-gray-400"
/>
)}
{isSelected && (
<path
className="text-blue-600"
fillRule="evenodd"
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm13.36-1.814a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z"
clipRule="evenodd"
/>
)}
</motion.svg>
);
}
import {
Button,
GridList,
Item
} from 'react-aria-components';
import type {
Selection,
SelectionMode
} from 'react-aria-components';
import {
animate,
AnimatePresence,
motion,
useIsPresent,
useMotionTemplate,
useMotionValue,
useMotionValueEvent
} from 'framer-motion';
import {
useRef,
useState
} from 'react';
import type {CSSProperties} from 'react';
const MotionItem =
motion(Item);
const inertiaTransition =
{
type:
'inertia' as const,
bounceStiffness: 300,
bounceDamping: 40,
timeConstant: 300
};
function SwipableList() {
let [items, setItems] =
useState(
messages.emails
);
let [
selectedKeys,
setSelectedKeys
] = useState<
Selection
>(new Set());
let [
selectionMode,
setSelectionMode
] = useState<
SelectionMode
>('none');
let onDelete = () => {
setItems(
items.filter((i) =>
selectedKeys !==
'all' &&
!selectedKeys
.has(i.id)
)
);
setSelectedKeys(
new Set()
);
setSelectionMode(
'none'
);
};
return (
<div className="flex flex-col h-full max-h-[500px] sm:w-[400px] -mx-[14px] sm:mx-0">
{/* Toolbar */}
<div className="flex pb-4 justify-between">
<Button
className="text-blue-600 text-lg outline-none bg-transparent border-none transition pressed:text-blue-700 focus-visible:ring disabled:text-gray-400"
style={{
opacity:
selectionMode ===
'none'
? 0
: 1
}}
isDisabled={selectedKeys !==
'all' &&
selectedKeys
.size ===
0}
onPress={onDelete}
>
Delete
</Button>
<Button
className="text-blue-600 text-lg outline-none bg-transparent border-none transition pressed:text-blue-700 focus-visible:ring"
onPress={() => {
setSelectionMode(
(
m
) => (m ===
'none'
? 'multiple'
: 'none')
);
setSelectedKeys(
new Set()
);
}}
>
{selectionMode ===
'none'
? 'Edit'
: 'Cancel'}
</Button>
</div>
<GridList
className="relative flex-1 overflow-auto"
aria-label="Inbox"
onAction={selectionMode ===
'none'
? () => {}
: undefined}
selectionMode={selectionMode}
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
>
<AnimatePresence>
{items.map((
item
) => (
<ListItem
key={item
.id}
id={item
.id}
textValue={[
item
.sender,
item
.date,
item
.subject,
item
.message
].join(
'\n'
)}
onRemove={() =>
setItems(
items
.filter(
(
i
) =>
i !==
item
)
)}
>
<div className="flex flex-col text-md cursor-default">
<div className="flex justify-between">
<p className="font-bold text-lg m-0">
{item
.sender}
</p>
<p className="text-gray-500 m-0">
{item
.date}
</p>
</div>
<p className="m-0">
{item
.subject}
</p>
<p className="line-clamp-2 text-gray-500 dark:text-gray-400 m-0">
{item
.message}
</p>
</div>
</ListItem>
))}
</AnimatePresence>
</GridList>
</div>
);
}
function ListItem(
{
id,
children,
textValue,
onRemove
}
) {
let ref = useRef(null);
let x = useMotionValue(
0
);
let isPresent =
useIsPresent();
let xPx =
useMotionTemplate` px`;
// Align the text in the remove button to the left if the
// user has swiped at least 80% of the width.
let [align, setAlign] =
useState('end');
useMotionValueEvent(
x,
'change',
(x) => {
let a =
x <
-ref.current
?.offsetWidth *
0.8
? 'start'
: 'end';
setAlign(a);
}
);
return (
<MotionItem
id={id}
textValue={textValue}
className="outline-none group relative overflow-clip border-t border-0 border-solid last:border-b border-gray-200 dark:border-gray-800 pressed:bg-gray-200 dark:pressed:bg-gray-800 selected:bg-gray-200 dark:selected:bg-gray-800 focus-visible:outline focus-visible:outline-blue-600 focus-visible:-outline-offset-2"
layout
transition={{
duration: 0.25
}}
exit={{
opacity: 0
}}
// Take item out of the flow if it is being removed.
style={{
position:
isPresent
? 'relative'
: 'absolute'
}}
>
{/* @ts-ignore - Framer Motion's types don't handle functions properly. */}
{(
{
selectionMode,
isSelected
}
) => (
// Content of the item can be swiped to reveal the delete button, or fully swiped to delete.
<motion.div
ref={ref}
style={{
x,
'--x': xPx
} as CSSProperties}
className="flex items-center"
drag={selectionMode ===
'none'
? 'x'
: undefined}
dragConstraints={{
right: 0
}}
onDragEnd={(
e,
{ offset }
) => {
// If the user dragged past 80% of the width, remove the item
// otherwise animate back to the nearest snap point.
let v =
offset.x >
-20
? 0
: -100;
if (
x.get() <
-ref
.current
.offsetWidth *
0.8
) {
v = -ref
.current
.offsetWidth;
onRemove();
}
animate(
x,
v,
{
...inertiaTransition,
min: v,
max: v
}
);
}}
onDragStart={() => {
// Cancel react-aria press event when dragging starts.
document
.dispatchEvent(
new PointerEvent(
'pointercancel'
)
);
}}
>
{selectionMode ===
'multiple' &&
(
<SelectionCheckmark
isSelected={isSelected}
/>
)}
<motion.div
layout
layoutDependency={selectionMode}
transition={{
duration:
0.25
}}
className="relative flex items-center px-4 py-2 z-10"
>
{children}
</motion.div>
{selectionMode ===
'none' && (
<Button
className="bg-red-600 pressed:bg-red-700 cursor-default text-lg outline-none border-none transition-colors text-white flex items-center absolute top-0 left-[100%] py-2 h-full z-0 isolate focus-visible:outline focus-visible:outline-blue-600 focus-visible:-outline-offset-2"
style={{
// Calculate the size of the button based on the drag position,
// which is stored in a CSS variable above.
width:
'max(100px, calc(-1 * var(--x)))',
justifyContent:
align
}}
onPress={onRemove}
// Move the button into view when it is focused with the keyboard
// (e.g. via the arrow keys).
onFocus={() =>
x.set(
-100
)}
onBlur={() =>
x.set(0)}
>
<motion.span
initial={false}
className="px-4"
animate={{
// Whenever the alignment changes, perform a keyframe animation
// between the previous position and new position. This is done
// by calculating a transform for the previous alignment and
// animating it back to zero.
transform:
align ===
'start'
? [
'translateX(calc(-100% - var(--x)))',
'translateX(0)'
]
: [
'translateX(calc(100% + var(--x)))',
'translateX(0)'
]
}}
>
Delete
</motion.span>
</Button>
)}
</motion.div>
)}
</MotionItem>
);
}
function SelectionCheckmark(
{ isSelected }
) {
return (
<motion.svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="currentColor"
className="w-6 h-6 flex-shrink-0 ml-4"
initial={{
x: -40
}}
animate={{ x: 0 }}
transition={{
duration: 0.25
}}
>
{!isSelected && (
<circle
r={9}
cx={12}
cy={12}
stroke="currentColor"
fill="none"
strokeWidth={1}
className="text-gray-400"
/>
)}
{isSelected && (
<path
className="text-blue-600"
fillRule="evenodd"
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm13.36-1.814a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z"
clipRule="evenodd"
/>
)}
</motion.svg>
);
}
Tailwind config#
This example uses the tailwindcss-react-aria-components plugin. Add it to your tailwind.config.js
:
module.exports = {
// ...
plugins: [
require('tailwindcss-react-aria-components')
]
};
module.exports = {
// ...
plugins: [
require('tailwindcss-react-aria-components')
]
};
module.exports = {
// ...
plugins: [
require(
'tailwindcss-react-aria-components'
)
]
};
Components#
GridList
A grid list displays a list of interactive items, with keyboard navigation, row selection, and actions.
Button
A button allows a user to perform an action.