UI Components Medium
Bottom Sheet
A slide-up bottom sheet modal with drag-to-dismiss, backdrop, and two variants — an info sheet and an actions sheet. No libraries.
Open in Lab
MCP
vanilla-js css
Targets: JS HTML React Native
Expo Snack
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
background: #f5f5f7;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
display: flex;
align-items: center;
justify-content: center;
}
.demo-page {
text-align: center;
padding: 40px 20px;
}
.demo-page h1 {
font-size: 24px;
font-weight: 700;
color: #111;
margin-bottom: 12px;
}
.demo-page p {
font-size: 15px;
color: #666;
margin-bottom: 32px;
max-width: 320px;
margin-inline: auto;
}
.buttons {
display: flex;
gap: 12px;
flex-wrap: wrap;
justify-content: center;
}
.open-btn {
padding: 13px 24px;
background: #6366f1;
color: #fff;
border: none;
border-radius: 12px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: transform 0.15s, opacity 0.15s;
}
.open-btn:active {
transform: scale(0.96);
opacity: 0.9;
}
.open-btn.secondary {
background: #fff;
color: #6366f1;
border: 2px solid #6366f1;
}
/* Backdrop */
.sheet-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
z-index: 100;
}
.sheet-backdrop.visible {
opacity: 1;
pointer-events: all;
}
/* Sheet */
.bottom-sheet {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #fff;
border-radius: 20px 20px 0 0;
z-index: 101;
transform: translateY(100%);
transition: transform 0.35s cubic-bezier(0.32, 0.72, 0, 1);
touch-action: none;
max-height: 80vh;
overflow: hidden;
box-shadow: 0 -4px 40px rgba(0, 0, 0, 0.12);
}
.bottom-sheet.open {
transform: translateY(0);
}
.bottom-sheet.dragging {
transition: none;
}
.sheet-handle {
width: 36px;
height: 4px;
background: #d1d5db;
border-radius: 2px;
margin: 12px auto 8px;
cursor: grab;
flex-shrink: 0;
}
.sheet-handle:active {
cursor: grabbing;
}
.sheet-content {
padding: 8px 24px 48px;
overflow-y: auto;
max-height: calc(80vh - 40px);
}
.sheet-title {
font-size: 20px;
font-weight: 700;
color: #111;
margin-bottom: 12px;
}
.sheet-body {
font-size: 15px;
color: #555;
line-height: 1.6;
margin-bottom: 24px;
}
.sheet-meta {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 24px;
}
.meta-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: #f9fafb;
border-radius: 10px;
}
.meta-item span {
font-size: 13px;
color: #888;
}
.meta-item strong {
font-size: 14px;
color: #111;
}
.sheet-close-btn {
width: 100%;
padding: 14px;
background: #6366f1;
color: #fff;
border: none;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.sheet-close-btn:active {
opacity: 0.85;
}
/* Action list */
.action-list {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 8px;
}
.action-item {
display: flex;
align-items: center;
gap: 16px;
width: 100%;
padding: 16px;
border: none;
background: none;
border-radius: 12px;
font-size: 16px;
font-weight: 500;
color: #111;
cursor: pointer;
text-align: left;
transition: background 0.15s;
}
.action-item:active {
background: #f3f4f6;
}
.action-item svg {
width: 20px;
height: 20px;
flex-shrink: 0;
color: #555;
}
.action-item.danger {
color: #ef4444;
}
.action-item.danger svg {
color: #ef4444;
}const backdrop = document.getElementById("backdrop");
let activeSheet = null;
function openSheet(id) {
const sheet = document.getElementById(`sheet-${id}`);
if (!sheet) return;
activeSheet = sheet;
backdrop.classList.add("visible");
sheet.classList.add("open");
document.body.style.overflow = "hidden";
}
function closeSheet() {
if (!activeSheet) return;
activeSheet.classList.remove("open");
activeSheet.style.transform = "";
backdrop.classList.remove("visible");
document.body.style.overflow = "";
activeSheet = null;
}
// Open buttons
document.querySelectorAll(".open-btn").forEach((btn) => {
btn.addEventListener("click", () => openSheet(btn.dataset.sheet));
});
// Backdrop closes sheet
backdrop.addEventListener("click", closeSheet);
// Close buttons inside sheets
document.querySelectorAll("[data-close]").forEach((el) => {
el.addEventListener("click", closeSheet);
});
// Drag-to-dismiss on each sheet handle
document.querySelectorAll(".bottom-sheet").forEach((sheet) => {
const handle = sheet.querySelector(".sheet-handle");
let startY = 0;
let currentY = 0;
let dragging = false;
function onTouchStart(e) {
startY = e.touches[0].clientY;
dragging = true;
sheet.classList.add("dragging");
}
function onTouchMove(e) {
if (!dragging) return;
currentY = e.touches[0].clientY - startY;
if (currentY < 0) currentY = 0;
sheet.style.transform = `translateY(${currentY}px)`;
}
function onTouchEnd() {
if (!dragging) return;
dragging = false;
sheet.classList.remove("dragging");
if (currentY > 120) {
closeSheet();
} else {
sheet.style.transform = "";
}
currentY = 0;
}
handle.addEventListener("touchstart", onTouchStart, { passive: true });
window.addEventListener("touchmove", onTouchMove, { passive: true });
window.addEventListener("touchend", onTouchEnd);
});<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="style.css" />
<title>Bottom Sheet</title>
</head>
<body>
<div class="demo-page">
<h1>Bottom Sheet</h1>
<p>Tap a button to open the sheet. Drag the handle or swipe down to dismiss.</p>
<div class="buttons">
<button class="open-btn" data-sheet="info">Open Info Sheet</button>
<button class="open-btn secondary" data-sheet="actions">Open Actions Sheet</button>
</div>
</div>
<div class="sheet-backdrop" id="backdrop"></div>
<!-- Info Sheet -->
<div class="bottom-sheet" id="sheet-info" role="dialog" aria-modal="true" aria-label="Info sheet">
<div class="sheet-handle" id="handle-info"></div>
<div class="sheet-content">
<h2 class="sheet-title">Item Details</h2>
<p class="sheet-body">This is a bottom sheet with draggable dismiss. Pull the handle downward or tap outside to close.</p>
<div class="sheet-meta">
<div class="meta-item"><span>Created</span><strong>March 6, 2026</strong></div>
<div class="meta-item"><span>Category</span><strong>Mobile UI</strong></div>
<div class="meta-item"><span>Status</span><strong>Active</strong></div>
</div>
<button class="sheet-close-btn" data-close="info">Close</button>
</div>
</div>
<!-- Actions Sheet -->
<div class="bottom-sheet" id="sheet-actions" role="dialog" aria-modal="true" aria-label="Actions sheet">
<div class="sheet-handle" id="handle-actions"></div>
<div class="sheet-content">
<h2 class="sheet-title">Actions</h2>
<div class="action-list">
<button class="action-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
Edit
</button>
<button class="action-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
Share
</button>
<button class="action-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
Save
</button>
<button class="action-item danger" data-close="actions">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
Delete
</button>
</div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>import React, { useRef, useEffect, useState, useCallback } from "react";
import {
View,
Text,
TouchableOpacity,
Animated,
PanResponder,
StyleSheet,
Dimensions,
ScrollView,
} from "react-native";
const { height: SCREEN_HEIGHT } = Dimensions.get("window");
const SHEET_MAX_HEIGHT = SCREEN_HEIGHT * 0.55;
const SNAP_THRESHOLD = 120;
/* ── Bottom Sheet ─────────────────────────────────────── */
interface BottomSheetProps {
visible: boolean;
onClose: () => void;
children: React.ReactNode;
}
function BottomSheet({ visible, onClose, children }: BottomSheetProps) {
const translateY = useRef(new Animated.Value(SHEET_MAX_HEIGHT)).current;
const backdropOpacity = useRef(new Animated.Value(0)).current;
const [mounted, setMounted] = useState(false);
const open = useCallback(() => {
setMounted(true);
Animated.parallel([
Animated.spring(translateY, {
toValue: 0,
useNativeDriver: true,
friction: 9,
tension: 55,
}),
Animated.timing(backdropOpacity, {
toValue: 1,
duration: 250,
useNativeDriver: true,
}),
]).start();
}, [translateY, backdropOpacity]);
const close = useCallback(() => {
Animated.parallel([
Animated.timing(translateY, {
toValue: SHEET_MAX_HEIGHT,
duration: 250,
useNativeDriver: true,
}),
Animated.timing(backdropOpacity, {
toValue: 0,
duration: 250,
useNativeDriver: true,
}),
]).start(() => {
setMounted(false);
onClose();
});
}, [translateY, backdropOpacity, onClose]);
useEffect(() => {
if (visible) open();
else if (mounted) close();
}, [visible]);
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: (_, g) => g.dy > 4,
onPanResponderMove: (_, g) => {
if (g.dy > 0) {
translateY.setValue(g.dy);
}
},
onPanResponderRelease: (_, g) => {
if (g.dy > SNAP_THRESHOLD || g.vy > 0.8) {
close();
} else {
Animated.spring(translateY, {
toValue: 0,
useNativeDriver: true,
friction: 9,
}).start();
}
},
})
).current;
if (!mounted) return null;
return (
<View style={StyleSheet.absoluteFill}>
<Animated.View style={[styles.backdrop, { opacity: backdropOpacity }]}>
<TouchableOpacity style={StyleSheet.absoluteFill} activeOpacity={1} onPress={close} />
</Animated.View>
<Animated.View
style={[styles.sheet, { transform: [{ translateY }], maxHeight: SHEET_MAX_HEIGHT }]}
>
<View style={styles.handleArea} {...panResponder.panHandlers}>
<View style={styles.handle} />
</View>
<ScrollView style={styles.content} bounces={false} showsVerticalScrollIndicator={false}>
{children}
</ScrollView>
</Animated.View>
</View>
);
}
/* ── Styles ───────────────────────────────────────────── */
const styles = StyleSheet.create({
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0,0,0,0.55)",
},
sheet: {
position: "absolute",
bottom: 0,
left: 0,
right: 0,
backgroundColor: "#1e293b",
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
overflow: "hidden",
},
handleArea: {
alignItems: "center",
paddingVertical: 12,
},
handle: {
width: 40,
height: 5,
borderRadius: 3,
backgroundColor: "#475569",
},
content: {
paddingHorizontal: 20,
paddingBottom: 32,
},
});
/* ── Demo ─────────────────────────────────────────────── */
const ITEMS = [
"Notifications",
"Appearance",
"Privacy & Security",
"Storage",
"Language",
"Accessibility",
"About",
"Help & Feedback",
];
export default function App() {
const [open, setOpen] = useState(false);
return (
<View style={demo.container}>
<Text style={demo.heading}>Bottom Sheet</Text>
<TouchableOpacity style={demo.btn} onPress={() => setOpen(true)}>
<Text style={demo.btnText}>Open Sheet</Text>
</TouchableOpacity>
<BottomSheet visible={open} onClose={() => setOpen(false)}>
<Text style={demo.sheetTitle}>Settings</Text>
{ITEMS.map((item) => (
<TouchableOpacity key={item} style={demo.listItem}>
<Text style={demo.listText}>{item}</Text>
<Text style={demo.chevron}>{"\u203A"}</Text>
</TouchableOpacity>
))}
</BottomSheet>
</View>
);
}
const demo = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#0f172a",
justifyContent: "center",
alignItems: "center",
padding: 24,
},
heading: {
color: "#fff",
fontSize: 22,
fontWeight: "700",
marginBottom: 32,
},
btn: {
backgroundColor: "#3b82f6",
paddingVertical: 14,
paddingHorizontal: 28,
borderRadius: 10,
},
btnText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
sheetTitle: {
color: "#fff",
fontSize: 20,
fontWeight: "700",
marginBottom: 16,
},
listItem: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: 16,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: "#334155",
},
listText: {
color: "#e2e8f0",
fontSize: 16,
},
chevron: {
color: "#64748b",
fontSize: 22,
},
});Bottom Sheet
A mobile-native bottom sheet that slides up from the bottom of the screen. Supports drag-to-dismiss via touch events, a semi-transparent backdrop, and two usage patterns: info sheet and actions sheet.
How it works
- Clicking a trigger button shows the backdrop and slides the sheet up with
translateY - The sheet handle captures
touchstart/touchmove/touchendto track drag distance - Dragging down more than 120px dismisses the sheet; otherwise it snaps back
- Clicking the backdrop or a close/action button also dismisses
Variants included
- Info sheet — title, body text, metadata rows, and a close button
- Actions sheet — icon action list (Edit, Share, Save, Delete)
Customization
- Set
max-heighton.bottom-sheetto control how tall the sheet can grow - Add
overscroll-behavior: containon.sheet-contentto prevent body scroll - Extend with multiple snap points for a more complex sheet