Aspect Ratio
Responsive media with smooth aspect ratio transitions.
Preview
"use client";
import { type KeyboardEvent, useId, useState } from "react";
import { AspectRatio, AspectRatioImage } from "@/components/motion/aspect-ratio";
import { Tabs, TabsList, TabsTrigger } from "@/components/motion/tabs";
export function AspectRatioUsage() {
const [value, setValue] = useState("16:9");
const id = useId();
const panelId = `${id}-preview`;
const ratio = value === "16:9" ? 16 / 9 : value === "1:1" ? 1 : 9 / 16;
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (!(event.target instanceof HTMLElement) || event.target.getAttribute("role") !== "tab") return;
const direction = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
if (!direction && event.key !== "Home" && event.key !== "End") return;
const tabs = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="tab"]'));
const index = tabs.indexOf(event.target as HTMLButtonElement);
const next = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1 : (index + direction + tabs.length) % tabs.length;
event.preventDefault();
const tab = tabs[next];
const nextValue = tab?.dataset.tabsValue;
if (!nextValue) return;
tab.focus();
setValue(nextValue);
};
return (
<Tabs value={value} onValueChange={setValue} className="w-full max-w-md">
<TabsList aria-label="Aspect ratio" onKeyDown={onKeyDown}>
{["16:9", "1:1", "9:16"].map((item) => (
<TabsTrigger key={item} value={item} id={`${id}-${item}`} aria-controls={panelId} tabIndex={value === item ? 0 : -1}>
{item}
</TabsTrigger>
))}
</TabsList>
{/* biome-ignore lint/a11y/noNoninteractiveTabindex: A media-only tab panel needs a keyboard entry point. */}
<div id={panelId} role="tabpanel" aria-labelledby={`${id}-${value}`} tabIndex={0} className="mt-6">
<AspectRatio ratio={ratio} className="bg-muted" style={{ borderRadius: 16 }}>
<AspectRatioImage
src="https://images.unsplash.com/photo-1470770841072-f978cf4d019e?auto=format&fit=crop&w=1000&q=80"
alt="A lakeside cabin beneath mountains, reflected in the water"
width={1000}
height={667}
/>
</AspectRatio>
</div>
</Tabs>
);
}
"use client";
// beui.dev/components/motion/aspect-ratio
import { type HTMLMotionProps, motion, useReducedMotion } from "motion/react";
import { createContext, type ReactNode, useContext, useMemo } from "react";
import { SPRING_LAYOUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
type AspectRatioContextValue = { ratio: number; animated: boolean };
const AspectRatioContext = createContext<AspectRatioContextValue | null>(null);
export interface AspectRatioProps
extends Omit<HTMLMotionProps<"div">, "children" | "layout" | "layoutDependency"> {
children?: ReactNode;
/** Width divided by height, for example 16 / 9. Must be finite and positive. */
ratio?: number;
/** Morph layout changes. Reduced motion always skips movement. */
animated?: boolean;
/** Classes for the content layer, such as alignment or padding. */
contentClassName?: string;
}
export function AspectRatio({
ratio = 1,
animated = true,
children,
className,
contentClassName,
style,
...props
}: AspectRatioProps) {
const reduce = useReducedMotion();
const enabled = animated && !reduce;
const context = useMemo(() => ({ ratio, animated: enabled }), [ratio, enabled]);
if (!Number.isFinite(ratio) || ratio <= 0) {
throw new RangeError("AspectRatio: ratio must be a finite, positive number.");
}
return (
<AspectRatioContext.Provider value={context}>
<motion.div
{...props}
layout={enabled}
transition={{ layout: SPRING_LAYOUT }}
className={cn("relative w-full min-w-0 overflow-hidden", className)}
style={{ ...style, aspectRatio: ratio }}
>
<div className={cn("absolute inset-0", contentClassName)}>{children}</div>
</motion.div>
</AspectRatioContext.Provider>
);
}
export interface AspectRatioImageProps
extends Omit<HTMLMotionProps<"img">, "layout" | "layoutDependency" | "width" | "height"> {
alt: string;
/** Intrinsic width in pixels, used to reserve the image's cover crop. */
width: number;
/** Intrinsic height in pixels. Keep these dimensions accurate for the source. */
height: number;
}
export function AspectRatioImage({
width,
height,
className,
style,
draggable = false,
...props
}: AspectRatioImageProps) {
const context = useContext(AspectRatioContext);
if (!context) throw new Error("AspectRatioImage must be inside AspectRatio.");
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
throw new RangeError("AspectRatioImage: width and height must be finite, positive numbers.");
}
const fillWidth = context.ratio >= width / height;
return (
<div className="absolute inset-0 grid grid-cols-1 grid-rows-1 place-items-center">
{/* The image box keeps its intrinsic ratio at both endpoints. Its layout
projection therefore scales uniformly while the frame reshapes. */}
{/* biome-ignore lint/performance/noImgElement: Copy-paste registry components work outside Next.js. */}
<motion.img
{...props}
width={width}
height={height}
draggable={draggable}
layout={context.animated}
transition={{ layout: SPRING_LAYOUT }}
className={cn("col-start-1 row-start-1 block max-w-none", className)}
style={{
...style,
width: fillWidth ? "100%" : "auto",
height: fillWidth ? "auto" : "100%",
}}
/>
</div>
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// beui.dev/components/motion/aspect-ratio
import { type HTMLMotionProps, motion, useReducedMotion } from "motion/react";
import { createContext, type ReactNode, useContext, useMemo } from "react";
import { SPRING_LAYOUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
type AspectRatioContextValue = { ratio: number; animated: boolean };
const AspectRatioContext = createContext<AspectRatioContextValue | null>(null);
export interface AspectRatioProps
extends Omit<HTMLMotionProps<"div">, "children" | "layout" | "layoutDependency"> {
children?: ReactNode;
/** Width divided by height, for example 16 / 9. Must be finite and positive. */
ratio?: number;
/** Morph layout changes. Reduced motion always skips movement. */
animated?: boolean;
/** Classes for the content layer, such as alignment or padding. */
contentClassName?: string;
}
export function AspectRatio({
ratio = 1,
animated = true,
children,
className,
contentClassName,
style,
...props
}: AspectRatioProps) {
const reduce = useReducedMotion();
const enabled = animated && !reduce;
const context = useMemo(() => ({ ratio, animated: enabled }), [ratio, enabled]);
if (!Number.isFinite(ratio) || ratio <= 0) {
throw new RangeError("AspectRatio: ratio must be a finite, positive number.");
}
return (
<AspectRatioContext.Provider value={context}>
<motion.div
{...props}
layout={enabled}
transition={{ layout: SPRING_LAYOUT }}
className={cn("relative w-full min-w-0 overflow-hidden", className)}
style={{ ...style, aspectRatio: ratio }}
>
<div className={cn("absolute inset-0", contentClassName)}>{children}</div>
</motion.div>
</AspectRatioContext.Provider>
);
}
export interface AspectRatioImageProps
extends Omit<HTMLMotionProps<"img">, "layout" | "layoutDependency" | "width" | "height"> {
alt: string;
/** Intrinsic width in pixels, used to reserve the image's cover crop. */
width: number;
/** Intrinsic height in pixels. Keep these dimensions accurate for the source. */
height: number;
}
export function AspectRatioImage({
width,
height,
className,
style,
draggable = false,
...props
}: AspectRatioImageProps) {
const context = useContext(AspectRatioContext);
if (!context) throw new Error("AspectRatioImage must be inside AspectRatio.");
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
throw new RangeError("AspectRatioImage: width and height must be finite, positive numbers.");
}
const fillWidth = context.ratio >= width / height;
return (
<div className="absolute inset-0 grid grid-cols-1 grid-rows-1 place-items-center">
{/* The image box keeps its intrinsic ratio at both endpoints. Its layout
projection therefore scales uniformly while the frame reshapes. */}
{/* biome-ignore lint/performance/noImgElement: Copy-paste registry components work outside Next.js. */}
<motion.img
{...props}
width={width}
height={height}
draggable={draggable}
layout={context.animated}
transition={{ layout: SPRING_LAYOUT }}
className={cn("col-start-1 row-start-1 block max-w-none", className)}
style={{
...style,
width: fillWidth ? "100%" : "auto",
height: fillWidth ? "auto" : "100%",
}}
/>
</div>
);
}
"use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cancelFrame, frame, motion, MotionConfig, useReducedMotion, type Transition } from "motion/react";
import {
createContext,
useCallback,
useContext,
useId,
useLayoutEffect,
useRef,
useMemo,
useState,
type ButtonHTMLAttributes,
type HTMLAttributes,
type ReactNode,
} from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
type Variant = "pill" | "underline" | "segment";
type Ctx = {
value: string;
setValue: (v: string) => void;
layoutId: string;
variant: Variant;
};
const TabsCtx = createContext<Ctx | null>(null);
function useTabs() {
const ctx = useContext(TabsCtx);
if (!ctx) throw new Error("Tabs.* must be used inside <Tabs>");
return ctx;
}
// Settle without overshoot: a scrollable tab list would turn even a small
// overshoot into a transient scrollbar and layout shift.
// Scale stiffness and damping together for a quicker glide with the same feel.
const transition: Transition = {
type: "spring",
stiffness: 245,
damping: 36,
mass: 1.2,
};
export function Tabs({
defaultValue,
value,
onValueChange,
variant = "pill",
children,
className,
}: {
defaultValue?: string;
value?: string;
onValueChange?: (v: string) => void;
variant?: Variant;
children: ReactNode;
className?: string;
}) {
const [internal, setInternal] = useState(defaultValue ?? "");
const layoutId = useId();
const reduce = useReducedMotion();
const controlled = value !== undefined;
const current = controlled ? value : internal;
const setValue = useCallback(
(v: string) => {
if (!controlled) setInternal(v);
onValueChange?.(v);
},
[controlled, onValueChange],
);
const contextValue = useMemo(
() => ({ value: current, setValue, layoutId, variant }),
[current, layoutId, setValue, variant],
);
return (
<MotionConfig transition={reduce ? { duration: 0 } : transition}>
<TabsCtx.Provider value={contextValue}>
{/* layoutRoot: the indicator's layoutId measures in page coordinates, so
inside fixed/scrolled containers it would replay scroll offsets as
movement. The pill only ever travels within the list, so scoping
projection to the Tabs wrapper is always correct. */}
<motion.div layoutRoot className={className}>
{children}
</motion.div>
</TabsCtx.Provider>
</MotionConfig>
);
}
const listClasses: Record<Variant, string> = {
pill: "inline-flex items-center gap-1 rounded-full bg-card p-1",
underline: "inline-flex items-center gap-1 border-b border-border",
segment: "inline-flex items-center gap-0 rounded-lg bg-card p-0.5",
};
export function TabsList({
children,
className,
wrapperClassName,
...props
}: {
wrapperClassName?: string;
} & HTMLAttributes<HTMLDivElement>) {
const { variant, value } = useTabs();
const reduce = useReducedMotion();
const rootRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const viewportId = useId();
const [edges, setEdges] = useState({ overflow: false, left: false, right: false });
const measure = useCallback(() => {
const root = rootRef.current;
const viewport = viewportRef.current;
if (!root || !viewport) return;
// Overlay controls do not reduce the viewport or change its scroll range.
const overflow = viewport.scrollWidth > root.clientWidth + 1;
const max = Math.max(0, viewport.scrollWidth - viewport.clientWidth);
const rtl = getComputedStyle(viewport).direction === "rtl";
// Modern browsers expose negative scrollLeft in RTL. Clamp rubber-banding.
const fromLeft = Math.max(0, Math.min(max, rtl ? max + viewport.scrollLeft : viewport.scrollLeft));
const next = { overflow, left: fromLeft > 1, right: fromLeft < max - 1 };
setEdges((previous) => previous.overflow === next.overflow && previous.left === next.left && previous.right === next.right ? previous : next);
}, []);
const reveal = useCallback((tab: HTMLElement | null) => {
const viewport = viewportRef.current;
if (!viewport || !tab) return;
const frame = viewport.getBoundingClientRect();
const item = tab.getBoundingClientRect();
const max = Math.max(0, viewport.scrollWidth - viewport.clientWidth);
const rtl = getComputedStyle(viewport).direction === "rtl";
const fromLeft = Math.max(0, Math.min(max, rtl ? max + viewport.scrollLeft : viewport.scrollLeft));
// Keep the selected/focused label clear of the arrows over the faded edges.
const left = frame.left + (fromLeft > 1 ? 36 : 0);
const right = frame.right - (fromLeft < max - 1 ? 36 : 0);
const delta = item.left < left ? item.left - left : item.right > right ? item.right - right : 0;
// Scroll only this viewport; scrollIntoView can also move the whole page.
if (delta) viewport.scrollBy({ left: delta, behavior: reduce ? "instant" : "smooth" });
}, [reduce]);
useLayoutEffect(() => {
const root = rootRef.current;
const viewport = viewportRef.current;
const list = listRef.current;
if (!root || !viewport || !list) return;
const update = () => {
measure();
reveal(list.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]'));
};
const observer = new ResizeObserver(update);
observer.observe(root);
observer.observe(viewport);
observer.observe(list);
viewport.addEventListener("scroll", measure, { passive: true });
update();
return () => {
observer.disconnect();
viewport.removeEventListener("scroll", measure);
};
}, [measure, reveal]);
useLayoutEffect(() => {
// Children may change without a resize; controlled selection must also reveal.
void children;
void value;
void edges.overflow;
measure();
reveal(listRef.current?.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]') ?? null);
}, [children, value, edges.overflow, measure, reveal]);
useLayoutEffect(() => {
if (variant === "underline") return;
const list = listRef.current;
if (!list) return;
void children;
const labels = Array.from(list.querySelectorAll<HTMLElement>("[data-tabs-label]"));
const indicator = list.querySelector<HTMLElement>("[data-tabs-indicator]");
const target = list.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]');
if (!indicator || !target || target.dataset.tabsValue !== value) {
for (const label of labels) label.style.clipPath = "inset(0 100% 0 0)";
return;
}
let frames = 0;
let stillFrames = 0;
let previous: { left: number; right: number } | undefined;
const syncClips = () => {
const pill = (reduce ? target : indicator).getBoundingClientRect();
// Read ALL geometry before writing ANY masks. A loop per tab interleaved
// reads and writes, forcing the browser to flush styles repeatedly.
const clips = labels.map((label) => {
const bounds = label.getBoundingClientRect();
const left = Math.max(0, Math.min(bounds.width, pill.left - bounds.left));
const right = Math.max(0, Math.min(bounds.width, bounds.right - pill.right));
return left + right >= bounds.width ? "inset(0 100% 0 0)" : `inset(0 ${right}px 0 ${left}px)`;
});
labels.forEach((label, index) => {
if (label.style.clipPath !== clips[index]) label.style.clipPath = clips[index];
});
frames += 1;
stillFrames = previous && Math.abs(pill.left - previous.left) < 0.01 && Math.abs(pill.right - previous.right) < 0.01 ? stillFrames + 1 : 0;
previous = { left: pill.left, right: pill.right };
if (reduce || (frames > 2 && stillFrames >= 2)) cancelFrame(syncClips);
};
// One shared pass after Motion paints the projected pill keeps every label
// in sync, including labels crossed during a long or interrupted glide.
frame.postRender(syncClips, true);
return () => cancelFrame(syncClips);
}, [value, children, variant, reduce]);
const scroll = (direction: number) => {
const viewport = viewportRef.current;
if (viewport) viewport.scrollBy({ left: direction * viewport.clientWidth * 0.8, behavior: reduce ? "instant" : "smooth" });
};
const controlClass = "absolute inset-y-0 z-20 inline-flex w-9 items-center justify-center text-foreground transition-opacity hover:opacity-70 focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-0";
const surfaceClass = variant === "pill" ? "rounded-full bg-card" : variant === "segment" ? "rounded-lg bg-card" : "";
return (
<div ref={rootRef} className={cn("relative isolate flex w-full max-w-full min-w-0 items-center", edges.overflow && surfaceClass, wrapperClassName)}>
{edges.overflow && (
<button type="button" aria-label="Scroll tabs left" aria-controls={viewportId} disabled={!edges.left} onClick={() => scroll(-1)} className={cn(controlClass, "left-0 rounded-l-full")}>
<ChevronLeft size={20} aria-hidden="true" />
</button>
)}
<motion.div
ref={viewportRef}
id={viewportId}
layoutScroll
className={cn("w-full min-w-0 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", edges.overflow && "[border-radius:inherit]")}
style={edges.overflow ? {
maskImage: `linear-gradient(to right, ${edges.left ? "transparent, black 40px" : "black, black 0px"}, ${edges.right ? "black calc(100% - 40px), transparent" : "black 100%"})`,
} : undefined}
onFocusCapture={(event) => {
if (event.target instanceof HTMLElement && event.target.getAttribute("role") === "tab") reveal(event.target);
}}
>
<div {...props} ref={listRef} role="tablist" className={cn(listClasses[variant], "w-max", className)}>
{children}
</div>
</motion.div>
{edges.overflow && edges.left && (
<span aria-hidden="true" className="pointer-events-none absolute inset-y-0 left-0 z-10 w-10 rounded-l-[inherit] backdrop-blur-[2px] [mask-image:linear-gradient(to_right,black,transparent)]" />
)}
{edges.overflow && edges.right && (
<span aria-hidden="true" className="pointer-events-none absolute inset-y-0 right-0 z-10 w-10 rounded-r-[inherit] backdrop-blur-[2px] [mask-image:linear-gradient(to_left,black,transparent)]" />
)}
{edges.overflow && (
<button type="button" aria-label="Scroll tabs right" aria-controls={viewportId} disabled={!edges.right} onClick={() => scroll(1)} className={cn(controlClass, "right-0 rounded-r-full")}>
<ChevronRight size={20} aria-hidden="true" />
</button>
)}
</div>
);
}
export function TabsTrigger({
value,
children,
className,
indicatorClassName,
onClick,
...props
}: {
value: string;
indicatorClassName?: string;
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "value">) {
const { value: current, setValue, layoutId, variant } = useTabs();
const active = current === value;
// React owns the initial mask only; TabsList synchronizes subsequent masks.
const [initialClip] = useState(() => active ? "inset(0)" : "inset(0 100% 0 0)");
if (variant === "underline") {
return (
<button
{...props}
type="button"
role="tab"
aria-selected={active}
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented) setValue(value);
}}
className={cn(
"relative isolate px-3 pb-2.5 pt-1 -mb-px text-sm font-medium transition-colors min-h-[44px] inline-flex items-center whitespace-nowrap shrink-0",
active ? "text-foreground" : "text-muted-foreground hover:text-foreground",
className,
)}
>
{children}
{active ? (
<motion.span
layoutId={layoutId}
layout
className={cn(
"absolute bottom-0 left-0 right-0 h-px bg-primary",
indicatorClassName,
)}
/>
) : null}
</button>
);
}
const radius = variant === "pill" ? "rounded-full" : "rounded-md";
return (
<div className="relative shrink-0">
{active ? (
<motion.span
data-tabs-indicator=""
layoutId={layoutId}
layout
style={{ borderRadius: variant === "pill" ? 9999 : 8 }}
className={cn(
"absolute inset-0 bg-primary",
radius,
indicatorClassName,
)}
/>
) : null}
<button
{...props}
type="button"
role="tab"
aria-selected={active}
data-tabs-value={value}
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented) setValue(value);
}}
className={cn(
"relative z-10 inline-flex items-center justify-center whitespace-nowrap bg-transparent px-3.5 py-1.5 text-sm font-medium outline-none",
"text-muted-foreground hover:text-foreground",
radius,
className,
)}
>
{children}
<span
data-tabs-label=""
aria-hidden="true"
inert
className="pointer-events-none absolute inset-0 inline-flex items-center justify-center text-primary-foreground [gap:inherit] [padding:inherit]"
style={{ clipPath: initialClip }}
>
{children}
</span>
</button>
</div>
);
}
export function TabsContent({ value, children, className }: { value: string; children: ReactNode; className?: string }) {
const { value: current } = useTabs();
const reduce = useReducedMotion();
const active = current === value;
// Inactive panels stay mounted but hidden, so their content (e.g. source
// code) is present in the server-rendered HTML for crawlers and assistive
// tech, instead of being dropped from the DOM.
if (!active) {
return (
<div hidden className={className}>
{children}
</div>
);
}
return (
<motion.div
key={value}
initial={{ opacity: 0, y: reduce ? 0 : 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.18, ease: EASE_OUT }}
className={cn("mt-4", className)}
>
{children}
</motion.div>
);
}
API Reference
AspectRatio
ratio?numberWidth divided by height, for example 16 / 9. Must be finite and positive.
1animated?booleanMorph layout changes. Reduced motion always skips movement.
truecontentClassName?stringClasses for the content layer, such as alignment or padding.
—className?string—AspectRatioImage
altstring—widthnumberIntrinsic width in pixels, used to reserve the image's cover crop.
—heightnumberIntrinsic height in pixels. Keep these dimensions accurate for the source.
—className?string—Related components
Animated Alert
Composable feedback with smooth transitions.
Arc Picker
Radial text picker with top, bottom, left or right curves, smooth wheel movement and live selection on each scroll step. One persistent bracket frame grows and squeezes around each label. Supports drag momentum, keyboard input, disabled choices, controlled or uncontrolled values, forms and reduced motion.
Image Viewer
A composable React image viewer for galleries and attachment previews, with animated lightbox variants, custom thumbnails and controls, swipe and keyboard navigation, zoom and pan, accessible focus management, and controlled or uncontrolled selection.
Updated