Media
Lightbox
A thumbnail grid that opens a full-bleed viewer and pages between shots.
lightbox.tsx
import * as React from "react";
import { flushSync } from "react-dom";
import { Sheet } from "scrollsheet";
import { SkipBackIcon, SkipForwardIcon, XIcon } from "./icons";
/**
* Lightbox: the PhotoSwipe/GLightbox pattern. A thumbnail grid opens a
* full-bleed viewer that pages between shots without closing.
*
* Controlled `open` plus `detents={['full']}` and `disableDrag` — a lightbox
* should never half-dismiss under a horizontal swipe, so the sheet holds
* still. Paging is a horizontal scroll-snap track: the same native-scroll
* thesis as the sheet itself, turned sideways. Swipes get browser momentum
* and snap for free; buttons and arrow keys drive the same scroll position,
* and the counter reads the active slide back from it.
*
* Zoom follows the same rule. Pinch and double-tap only set a zoom level
* (a width/height on the slide's inner box); panning the zoomed shot is the
* browser's own two-axis scroll, momentum included. While a shot is zoomed
* the track stops paging, so horizontal pans stay inside the photo.
*
* Open and close morph the shot between its grid thumbnail and the viewer
* via the View Transitions API where supported; everywhere else the sheet's
* own enter/exit runs unchanged.
*
* Desktop: `.ex-lb-panel` in examples.css overrides the library's default
* right-docked drawer into a centered card — a CSS-only recipe (no new
* library prop), scoped to exactly this single-detent + disableDrag shape.
*/
interface Shot {
id: string;
title: string;
place: string;
from: string;
mid: string;
to: string;
}
const SHOTS: Shot[] = [
{
id: "dunes",
title: "Dunes at dusk",
place: "Merzouga",
from: "#2b2a4a",
mid: "#8a5a7a",
to: "#e8a06a",
},
{
id: "fjord",
title: "Fjord morning",
place: "Geiranger",
from: "#0f2b3d",
mid: "#2f6d7a",
to: "#9fd0c2",
},
{
id: "salt",
title: "Salt flat",
place: "Uyuni",
from: "#1b2440",
mid: "#5f6f9c",
to: "#e6e9f5",
},
{
id: "pines",
title: "Pines in fog",
place: "Shirakami",
from: "#12211c",
mid: "#2f5145",
to: "#8fae95",
},
{
id: "canyon",
title: "Slot canyon",
place: "Page",
from: "#3a1b12",
mid: "#9c4a22",
to: "#f0b071",
},
{
id: "aurora",
title: "Aurora",
place: "Tromsø",
from: "#050b1c",
mid: "#12474a",
to: "#5ef0b0",
},
];
interface ShotImageProps {
shot: Shot;
className?: string;
style?: React.CSSProperties;
}
function ShotImage({ shot, className, style }: ShotImageProps) {
const gradientId = `ex-lb-${shot.id}`;
return (
<svg
className={className}
style={style}
viewBox="0 0 390 620"
preserveAspectRatio="xMidYMid slice"
role="img"
aria-label={`${shot.title}, ${shot.place}`}
>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor={shot.from} />
<stop offset="0.55" stopColor={shot.mid} />
<stop offset="1" stopColor={shot.to} />
</linearGradient>
</defs>
<rect width="390" height="620" fill={`url(#${gradientId})`} />
<circle cx="278" cy="150" r="44" fill="#fff" opacity="0.16" />
<path d="M0 400 Q120 330 240 395 T390 375 V620 H0 Z" fill="#000" opacity="0.22" />
<path d="M0 490 Q150 420 300 485 T390 470 V620 H0 Z" fill="#000" opacity="0.3" />
</svg>
);
}
const MORPH_NAME = "ex-lb-shot";
const MAX_ZOOM = 3.5;
const TAP_ZOOM = 2.4;
type DocumentWithVT = Document & {
startViewTransition?: (update: () => void) => { finished: Promise<unknown> };
};
function reducedMotion(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
interface ZoomShotProps {
shot: Shot;
active: boolean;
onZoomChange: (zoomed: boolean) => void;
}
/**
* Pinch and double-tap only decide the zoom level; the zoomed shot pans via
* the browser's own two-axis scroll. Live pinch mutates styles directly
* instead of going through state — per-frame setState would re-render the
* whole gallery at gesture rate for nothing.
*/
function ZoomShot({ shot, active, onZoomChange }: ZoomShotProps) {
const scrollerRef = React.useRef<HTMLDivElement | null>(null);
const sizerRef = React.useRef<HTMLDivElement | null>(null);
const scaleRef = React.useRef(1);
const pointers = React.useRef(new Map<number, { x: number; y: number }>());
const pinchStart = React.useRef<{ dist: number; scale: number } | null>(null);
const lastTap = React.useRef<{ t: number; x: number; y: number } | null>(null);
const [zoomed, setZoomed] = React.useState(false);
const applyScale = (next: number, focus?: { x: number; y: number }) => {
const scroller = scrollerRef.current;
const sizer = sizerRef.current;
if (!scroller || !sizer) return;
const prev = scaleRef.current;
scaleRef.current = next;
// Zooming in is instant so the focus-point scroll below lands on real
// scroll range; only the release back to 1 animates (its scroll target
// is 0, which clamping reaches on its own).
sizer.style.transition =
next < prev && !pinchStart.current
? "width 180ms cubic-bezier(0.16, 1, 0.3, 1), height 180ms cubic-bezier(0.16, 1, 0.3, 1)"
: "none";
sizer.style.width = `${next * 100}%`;
sizer.style.height = `${next * 100}%`;
if (focus) {
// Keep the focus point stationary: its content coordinate scales by
// next/prev while its viewport position stays put.
scroller.scrollLeft = ((scroller.scrollLeft + focus.x) * next) / prev - focus.x;
scroller.scrollTop = ((scroller.scrollTop + focus.y) * next) / prev - focus.y;
}
const isZoomed = next > 1.001;
setZoomed(isZoomed);
onZoomChange(isZoomed);
};
// Swiping to another slide hands its zoom back.
React.useEffect(() => {
if (!active && scaleRef.current > 1) applyScale(1);
});
const toggleZoom = (x: number, y: number) => {
applyScale(scaleRef.current > 1.001 ? 1 : TAP_ZOOM, { x, y });
};
const onPointerDown = (event: React.PointerEvent) => {
pointers.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
if (pointers.current.size === 2) {
const [a, b] = [...pointers.current.values()];
pinchStart.current = {
dist: Math.hypot(a.x - b.x, a.y - b.y),
scale: scaleRef.current,
};
// Freeze track paging for the whole pinch, even before 1x is crossed.
onZoomChange(true);
}
};
const onPointerMove = (event: React.PointerEvent) => {
const entry = pointers.current.get(event.pointerId);
if (!entry) return;
entry.x = event.clientX;
entry.y = event.clientY;
const start = pinchStart.current;
const scroller = scrollerRef.current;
if (!start || !scroller || pointers.current.size < 2) return;
const [a, b] = [...pointers.current.values()];
const rect = scroller.getBoundingClientRect();
const mid = { x: (a.x + b.x) / 2 - rect.left, y: (a.y + b.y) / 2 - rect.top };
const dist = Math.hypot(a.x - b.x, a.y - b.y);
// Under-zoom to 0.7 is allowed live so the release has something to
// rubber-band back from.
const next = Math.min(MAX_ZOOM, Math.max(0.7, (start.scale * dist) / start.dist));
applyScale(next, mid);
};
const endPointer = (event: React.PointerEvent) => {
const wasPinch = pinchStart.current !== null;
const hadPointer = pointers.current.has(event.pointerId);
pointers.current.delete(event.pointerId);
if (wasPinch && pointers.current.size < 2) {
pinchStart.current = null;
if (scaleRef.current < 1.15) applyScale(1);
else onZoomChange(scaleRef.current > 1.001);
}
// Double-tap (touch): two quick releases in nearly the same spot.
// Mouse and trackpad go through onDoubleClick instead.
if (!wasPinch && hadPointer && event.pointerType === "touch") {
const scroller = scrollerRef.current;
if (!scroller) return;
const rect = scroller.getBoundingClientRect();
const tap = {
t: performance.now(),
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
const last = lastTap.current;
lastTap.current = tap;
if (last && tap.t - last.t < 300 && Math.hypot(tap.x - last.x, tap.y - last.y) < 30) {
lastTap.current = null;
toggleZoom(tap.x, tap.y);
}
}
};
return (
<div
ref={scrollerRef}
className="ex-lb-zoom"
data-zoomed={zoomed || undefined}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endPointer}
onPointerCancel={endPointer}
onDoubleClick={(event) => {
const rect = event.currentTarget.getBoundingClientRect();
toggleZoom(event.clientX - rect.left, event.clientY - rect.top);
}}
>
<div ref={sizerRef} className="ex-lb-zoom-sizer">
<ShotImage
shot={shot}
className="ex-lb-full"
style={active ? { viewTransitionName: MORPH_NAME } : undefined}
/>
</div>
</div>
);
}
export default function LightboxExample() {
const [index, setIndex] = React.useState<number | null>(null);
const [zoomed, setZoomed] = React.useState(false);
// When the browser can morph the thumbnail into the viewer, the morph IS
// the entrance — the sheet's own travel would tell a second, competing
// story, so it's switched off (--scrollsheet-travel: none via the class).
// Set in an effect so server and first client render agree.
const [morphs, setMorphs] = React.useState(false);
React.useEffect(() => {
setMorphs(Boolean((document as DocumentWithVT).startViewTransition));
}, []);
const trackRef = React.useRef<HTMLDivElement | null>(null);
const gridRef = React.useRef<HTMLDivElement | null>(null);
const openedAt = React.useRef(0);
const open = index !== null;
const shot = index === null ? null : SHOTS[index]!;
// Land the track on the tapped thumbnail. The <dialog> is display:none
// until the library shows it in its own layout effect, so the track has
// no width at mount — the first animation frame is the earliest moment
// clientWidth is real, and it still runs before paint.
React.useEffect(() => {
if (!open) return;
const track = trackRef.current;
if (!track) return;
const frame = requestAnimationFrame(() => {
track.scrollLeft = track.clientWidth * openedAt.current;
});
return () => cancelAnimationFrame(frame);
}, [open]);
const openShot = (i: number) => {
const show = () => {
openedAt.current = i;
setIndex(i);
};
const doc = document as DocumentWithVT;
const thumb = gridRef.current?.children[i] as HTMLElement | undefined;
if (!doc.startViewTransition || reducedMotion() || !thumb) {
show();
return;
}
// The thumbnail is the morph's old snapshot; its name must be gone
// again by the time the new state is captured, or the viewer image
// would be a duplicate of it.
thumb.style.viewTransitionName = MORPH_NAME;
doc.startViewTransition(() => {
flushSync(show);
thumb.style.viewTransitionName = "";
});
};
const closeViewer = () => {
const i = index;
const thumb = i === null ? undefined : (gridRef.current?.children[i] as HTMLElement);
const hide = () => {
// Dismiss lands the grid where the viewer left off.
thumb?.scrollIntoView({ block: "nearest" });
setIndex(null);
setZoomed(false);
};
const doc = document as DocumentWithVT;
if (!doc.startViewTransition || reducedMotion() || !thumb) {
hide();
return;
}
doc
.startViewTransition(() => {
flushSync(hide);
thumb.style.viewTransitionName = MORPH_NAME;
})
.finished.finally(() => {
thumb.style.viewTransitionName = "";
});
};
// The scroll position is the source of truth for the active slide;
// counter and caption follow a swipe mid-gesture.
const onTrackScroll = (event: React.UIEvent<HTMLDivElement>) => {
const track = event.currentTarget;
if (track.clientWidth === 0) return;
const nearest = Math.round(track.scrollLeft / track.clientWidth);
setIndex((current) => (current === null || nearest === current ? current : nearest));
};
const step = (delta: number) => {
const track = trackRef.current;
if (!track || index === null) return;
const next = Math.min(SHOTS.length - 1, Math.max(0, index + delta));
track.scrollTo({ left: track.clientWidth * next, behavior: "smooth" });
};
// Arrow keys page the gallery. Bound on the panel rather than the document
// so it only applies while the lightbox owns the screen.
const onKeyDown = (event: React.KeyboardEvent) => {
if (event.key === "ArrowRight") step(1);
else if (event.key === "ArrowLeft") step(-1);
else return;
event.preventDefault();
};
return (
<>
<div className="ex-lb-grid" ref={gridRef}>
{SHOTS.map((item, i) => (
<button
type="button"
key={item.id}
className="ex-lb-thumb"
aria-label={`Open ${item.title}`}
onClick={() => openShot(i)}
>
<ShotImage shot={item} className="ex-lb-thumb-img" />
</button>
))}
</div>
<Sheet.Root
open={open}
onOpenChange={(next) => !next && closeViewer()}
detents={["full"]}
disableDrag
>
<Sheet.Content
className={morphs ? "ex-panel ex-lb-panel ex-lb-morph" : "ex-panel ex-lb-panel"}
aria-label="Photo viewer"
onKeyDown={onKeyDown}
>
{shot && (
<>
<div
className="ex-lb-track"
ref={trackRef}
data-zoomed={zoomed || undefined}
onScroll={onTrackScroll}
>
{SHOTS.map((item) => (
<div key={item.id} className="ex-lb-slide" aria-hidden={item.id !== shot.id}>
<ZoomShot shot={item} active={item.id === shot.id} onZoomChange={setZoomed} />
</div>
))}
</div>
<Sheet.Close className="ex-lb-close" aria-label="Close viewer">
<XIcon />
</Sheet.Close>
<div className="ex-lb-count" aria-live="polite">
{(index ?? 0) + 1} / {SHOTS.length}
</div>
<button
type="button"
className="ex-lb-nav ex-lb-prev"
aria-label="Previous photo"
disabled={index === 0}
onClick={() => step(-1)}
>
<SkipBackIcon />
</button>
<button
type="button"
className="ex-lb-nav ex-lb-next"
aria-label="Next photo"
disabled={index === SHOTS.length - 1}
onClick={() => step(1)}
>
<SkipForwardIcon />
</button>
<div className="ex-lb-caption">
<div className="ex-row-title">{shot.title}</div>
<div className="ex-row-sub">{shot.place}</div>
</div>
</>
)}
</Sheet.Content>
</Sheet.Root>
</>
);
}styles.css
Only the rules this example uses. Save it next to the component and it runs standalone.
/* ─────────────────────────────────────────────────────────────────────────
Shared example styling. Imported once by each app (playground/site) so
every example in this directory looks the same everywhere it runs, without
any example .tsx importing anything but "scrollsheet" and "react".
Self-contained token set (--ex-*), namespaced so it never collides with a
host app's own design tokens. scrollsheet itself ships a real default
look for the panel (background/radius/shadow, see src/internal/styles.ts).
Everything below styles the *content* inside it, plus a few panel
variants (inset card, toast) that intentionally override that default.
───────────────────────────────────────────────────────────────────────── */
:root {
--ex-bg-elevated: #ffffff;
--ex-bg-inset: #f5f5f4;
--ex-fg: #1c1917;
--ex-fg-muted: #57534e;
--ex-fg-faint: #78716c;
--ex-border: #e7e5e4;
--ex-border-strong: #d6d3d1;
--ex-accent: #5a45e8;
--ex-accent-fg: #ffffff;
--ex-accent-soft: #efecff;
--ex-danger: #c4392f;
--ex-danger-soft: #fbe9e6;
--ex-success: #15803d;
--ex-success-soft: #e8f5ec;
--ex-shadow-sm: 0 1px 2px rgb(12 11 10 / 0.08);
--ex-shadow-md: 0 8px 24px -8px rgb(12 11 10 / 0.2);
--ex-shadow-lg: 0 24px 64px -20px rgb(12 11 10 / 0.32);
--ex-radius-sm: 8px;
--ex-radius-md: 14px;
--ex-radius-lg: 22px;
--ex-radius-xl: 32px;
--ex-radius-pill: 999px;
--ex-font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--ex-bg-elevated: #1c1c1e;
--ex-bg-inset: #222225;
--ex-fg: #f4f4f5;
--ex-fg-muted: #a1a1aa;
--ex-fg-faint: #71717a;
--ex-border: #303033;
--ex-border-strong: #3f3f46;
--ex-accent: #9385ff;
--ex-accent-fg: #0a0a0b;
--ex-accent-soft: #23204a;
--ex-danger: #ff7a70;
--ex-danger-soft: #2c1917;
--ex-success: #4ade80;
--ex-success-soft: #12271a;
--ex-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.4);
--ex-shadow-md: 0 8px 24px -8px rgb(0 0 0 / 0.5);
--ex-shadow-lg: 0 24px 64px -20px rgb(0 0 0 / 0.6);
}
}
/* ── Panel content wrappers ──────────────────────────────────────────────
Sheet.Content carries .ex-panel (or .ex-panel-inset); a body wrapper
inside supplies the padding, since the handle sits outside it. */
.ex-panel {
background: var(--ex-bg-elevated);
color: var(--ex-fg);
}
.ex-row-title {
font-weight: 600;
}
.ex-row-sub {
color: var(--ex-fg-muted);
font-size: 0.85rem;
}
@keyframes ex-wallet-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes ex-spin {
to {
transform: rotate(360deg);
}
}
.ex-photo-caption .ex-row-sub {
color: rgb(255 255 255 / 0.75);
}
/* ── Lightbox (thumbnail grid + paging viewer) ──────────────────────────── */
.ex-lb-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
padding: 12px;
}
.ex-lb-thumb {
position: relative;
aspect-ratio: 1;
padding: 0;
border: none;
border-radius: 10px;
overflow: hidden;
cursor: pointer;
background: none;
transition: transform 120ms ease;
}
.ex-lb-thumb:hover {
transform: scale(1.04);
}
.ex-lb-thumb-img {
width: 100%;
height: 100%;
display: block;
}
.ex-lb-panel {
display: flex;
flex-direction: column;
padding: 0;
background: #000;
--scrollsheet-safe-area: 0px;
}
/* Desktop: present as a centered lightbox card instead of the library's
default right-docked drawer. Two library levers, no new API:
--scrollsheet-inset-bottom (already read by measure() to flag the sheet
data-scrollsheet-detached, which centers it vertically for a single
detents= {
['full']
}
panel for free) plus overriding the dock's left/right/
width back to a max-width + auto-margin center, the same recipe core.css's
own top-side desktop rule uses. Only correct for a single ['full'] detent
with disableDrag, exactly what this example uses — a shorter detent stays
bottom-anchored inside the floating region instead of centering, since the
panel's near edge is fixed and only its far edge moves. */
@media (min-width: 768px) {
.ex-lb-panel {
--scrollsheet-inset-bottom: var(--scrollsheet-desktop-margin, 24px);
left: var(--scrollsheet-inset-x, 0px);
right: var(--scrollsheet-inset-x, 0px);
width: auto;
max-width: min(480px, calc(100% - 2 * var(--scrollsheet-desktop-margin, 24px)));
margin-inline: auto;
}
/* The card hugs the photo instead of standing full-viewport-tall with
dead space under it. Doubled class outranks the injected core rule's
height (same specificity would lose on source order — core.css loads
after this file). It stays anchored to the floating region's bottom
edge — the panel's coordinate space is the canvas (viewport plus
detent runway), so viewport-centering tricks land in the wrong space.
Safe only because this sheet is single-['full']-detent with
disableDrag: nothing reads the panel height for snap math. */
.ex-panel.ex-lb-panel {
height: fit-content;
max-height: calc(100% - 2 * var(--scrollsheet-desktop-margin, 24px));
}
}
/* Horizontal scroll-snap paging track: swipes get browser momentum and
snap natively. Axis-orthogonal to the sheet's own vertical engine, so the
browser arbitrates the two gestures with no JS. */
.ex-lb-track {
flex: 1;
min-height: 0;
display: flex;
overflow-x: auto;
overscroll-behavior-x: contain;
scroll-snap-type: x mandatory;
scrollbar-width: none;
}
.ex-lb-track::-webkit-scrollbar {
display: none;
}
.ex-lb-slide {
flex: 0 0 100%;
min-width: 100%;
display: flex;
scroll-snap-align: center;
}
/* While a shot is zoomed (or a pinch is in flight) the track stops paging,
so horizontal pans stay inside the photo. */
.ex-lb-track[data-zoomed] {
overflow-x: hidden;
}
/* Zoom scroller: pinch and double-tap set only the inner box's size; a
zoomed shot pans with the browser's own two-axis scroll. pan-x pan-y
without the pinch-zoom token keeps two-finger input out of the browser's
page zoom and delivers it as pointer events instead. */
.ex-lb-zoom {
flex: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
touch-action: pan-x pan-y;
cursor: zoom-in;
scrollbar-width: none;
}
.ex-lb-zoom::-webkit-scrollbar {
display: none;
}
.ex-lb-zoom[data-zoomed] {
overflow: auto;
cursor: zoom-out;
}
.ex-lb-zoom-sizer {
width: 100%;
height: 100%;
}
.ex-lb-full {
width: 100%;
height: 100%;
display: block;
}
/* Thumbnail-to-viewer morph (View Transitions API, progressive). */
::view-transition-group(ex-lb-shot) {
animation-duration: 280ms;
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}
/* While the morph carries the entrance, the sheet's own slide-in would
tell a second story — the class is only applied when the browser can
actually morph, so non-supporting browsers keep the travel. */
.ex-lb-morph {
--scrollsheet-travel: none;
}
.ex-lb-close {
position: absolute;
top: 14px;
right: 14px;
z-index: 1;
display: grid;
place-items: center;
width: 34px;
height: 34px;
border: none;
border-radius: 50%;
color: #fff;
background: rgb(0 0 0 / 0.45);
cursor: pointer;
}
.ex-lb-close:hover {
background: rgb(0 0 0 / 0.6);
}
.ex-lb-count {
position: absolute;
top: 18px;
left: 18px;
z-index: 1;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: #fff;
background: rgb(0 0 0 / 0.45);
}
.ex-lb-nav {
position: absolute;
top: 50%;
z-index: 1;
translate: 0 -50%;
display: grid;
place-items: center;
width: 40px;
height: 40px;
border: none;
border-radius: 50%;
color: #fff;
background: rgb(0 0 0 / 0.4);
cursor: pointer;
}
.ex-lb-nav:hover {
background: rgb(0 0 0 / 0.6);
}
.ex-lb-nav:disabled {
opacity: 0.35;
cursor: default;
}
.ex-lb-nav:disabled:hover {
background: rgb(0 0 0 / 0.4);
}
.ex-lb-prev {
left: 12px;
}
.ex-lb-next {
right: 12px;
}
.ex-lb-caption {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 18px 20px calc(18px + env(safe-area-inset-bottom, 0px));
background: linear-gradient(rgb(0 0 0 / 0), rgb(0 0 0 / 0.6));
color: #fff;
}
.ex-lb-caption .ex-row-sub {
color: rgb(255 255 255 / 0.75);
}icons.tsx
/**
* Inline lucide icons (ISC) used by the examples — kept dependency-free so
* the examples stay copy-pasteable without an icon package install.
* Generated file: edit the generator, not this by hand.
*/
import * as React from "react";
function Icon({ children, ...props }: React.ComponentProps<"svg">) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
{children}
</svg>
);
}
export function SkipBackIcon(props: React.ComponentProps<"svg">) {
return <Icon {...props}><path d="M17.971 4.285A2 2 0 0 1 21 6v12a2 2 0 0 1-3.029 1.715l-9.997-5.998a2 2 0 0 1-.003-3.432zM3 20V4"/></Icon>;
}
export function SkipForwardIcon(props: React.ComponentProps<"svg">) {
return <Icon {...props}><path d="M21 4v16M6.029 4.285A2 2 0 0 0 3 6v12a2 2 0 0 0 3.029 1.715l9.997-5.998a2 2 0 0 0 .003-3.432z"/></Icon>;
}
export function XIcon(props: React.ComponentProps<"svg">) {
return <Icon {...props}><path d="M18 6L6 18M6 6l12 12"/></Icon>;
}