Expandable Control New
A compact now-playing pill that springs open into a full music player.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/components-expandable-control.json02
Or copy the source below (2 files). Dependencies: lucide-react.
Source
components/niceui/ui/motion/ExpandableControl.tsx
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { Pause, Play, SkipBack, SkipForward, Volume2, VolumeX } from 'lucide-react';
import { cn } from '@/lib/cn';
import { useClickOutside, useEscape } from './hooks';
export type ExpandableControlProps = {
collapsed: ReactNode;
expanded: ReactNode;
collapsedSize?: { width: number; height: number };
expandedSize?: { width: number; height: number };
className?: string;
};
/** A compact pill that springs open into a full control surface. */
export function ExpandableControl({
collapsed,
expanded,
collapsedSize = { width: 240, height: 52 },
expandedSize = { width: 360, height: 212 },
className,
}: ExpandableControlProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const close = useCallback(() => setOpen(false), []);
useEscape(open, close);
useClickOutside([ref], open, close);
const size = open ? expandedSize : collapsedSize;
return (
<div
ref={ref}
onClick={() => !open && setOpen(true)}
style={{ width: size.width, height: size.height, borderRadius: open ? 28 : size.height / 2 }}
className={cn(
'relative max-w-full overflow-hidden bg-ink-900 shadow-pop transition-all duration-[600ms] ease-[cubic-bezier(0.34,1.3,0.64,1)]',
!open && 'cursor-pointer hover:scale-[1.02] active:scale-[0.98]',
className,
)}
>
<div className={cn('absolute inset-0 transition-all', open ? 'pointer-events-none scale-95 opacity-0 blur-sm duration-150' : 'scale-100 opacity-100 blur-0 delay-150 duration-300')}>
{collapsed}
</div>
<div
className={cn('absolute left-0 top-0 transition-all', open ? 'scale-100 opacity-100 blur-0 delay-150 duration-500' : 'pointer-events-none scale-[0.97] opacity-0 blur-sm duration-150')}
style={{ width: expandedSize.width, height: expandedSize.height }}
>
{expanded}
</div>
</div>
);
}
function Eq({ on }: { on: boolean }) {
return (
<span className="flex h-4 items-end gap-[3px]">
{[0, 1, 2, 3].map((i) => (
<span
key={i}
className={cn('w-[3px] origin-bottom rounded-full bg-brand-400', on ? 'animate-bar-bounce' : 'scale-y-[0.3]')}
style={{ height: '100%', animationDelay: `${i * 150}ms`, animationDuration: `${0.8 + i * 0.15}s` }}
/>
))}
</span>
);
}
const fmt = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
const TRACK = { title: 'Midnight Transit', artist: 'Kaito & The Lanterns', length: 214 };
const ART = 'radial-gradient(circle at 30% 30%, #7dd3fc, transparent 60%), radial-gradient(circle at 70% 80%, #f59e0b, transparent 55%), #142b57';
export function ExpandableControlDemo() {
const [playing, setPlaying] = useState(true);
const [t, setT] = useState(63);
const [vol, setVol] = useState(70);
useEffect(() => {
if (!playing) return;
const id = window.setInterval(() => setT((v) => (v + 1) % TRACK.length), 1000);
return () => window.clearInterval(id);
}, [playing]);
const iconBtn = 'focus-ring flex h-10 w-10 items-center justify-center rounded-full text-ink-200 transition hover:bg-white/5 hover:text-ink-50 active:scale-90';
return (
<div className="flex h-[240px] flex-col items-center justify-center gap-3">
<ExpandableControl
collapsed={
<div className="flex h-full items-center gap-3 pl-2 pr-4">
<span className="h-9 w-9 shrink-0 rounded-full" style={{ background: ART, animation: playing ? 'spin 8s linear infinite' : undefined }} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-ink-50">{TRACK.title}</span>
<span className="block truncate text-xs text-ink-400">{TRACK.artist}</span>
</span>
<Eq on={playing} />
</div>
}
expanded={
<div className="flex h-full flex-col p-5" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-4">
<span className="h-16 w-16 shrink-0 rounded-2xl shadow-card" style={{ background: ART }} />
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-ink-50">{TRACK.title}</p>
<p className="truncate text-sm text-ink-400">{TRACK.artist}</p>
</div>
<Eq on={playing} />
</div>
<div className="mt-4">
<input type="range" min={0} max={TRACK.length} value={t} onChange={(e) => setT(Number(e.target.value))} aria-label="Seek" className="h-1 w-full cursor-pointer accent-brand-500" />
<div className="mt-1 flex justify-between font-mono text-[11px] text-ink-500">
<span>{fmt(t)}</span>
<span>-{fmt(TRACK.length - t)}</span>
</div>
</div>
<div className="mt-auto flex items-center justify-between">
<div className="flex items-center gap-1">
<button aria-label="Previous" className={iconBtn} onClick={() => setT(0)}><SkipBack className="h-4 w-4" /></button>
<button aria-label={playing ? 'Pause' : 'Play'} onClick={() => setPlaying((p) => !p)} className="focus-ring flex h-10 w-10 items-center justify-center rounded-full bg-ink-50 text-ink-950 transition hover:bg-white active:scale-90">
{playing ? <Pause key="pa" className="h-4 w-4 animate-scale-in" /> : <Play key="pl" className="h-4 w-4 animate-scale-in" />}
</button>
<button aria-label="Next" className={iconBtn} onClick={() => setT(0)}><SkipForward className="h-4 w-4" /></button>
</div>
<div className="flex items-center gap-2">
<button aria-label="Mute" onClick={() => setVol((v) => (v ? 0 : 70))} className="text-ink-400 transition hover:text-ink-100">
{vol ? <Volume2 className="h-4 w-4" /> : <VolumeX className="h-4 w-4" />}
</button>
<input type="range" min={0} max={100} value={vol} onChange={(e) => setVol(Number(e.target.value))} aria-label="Volume" className="h-1 w-20 cursor-pointer accent-brand-500" />
</div>
</div>
</div>
}
/>
<p className="text-xs text-ink-500">Click the pill · Esc or click outside to collapse</p>
</div>
);
}components/niceui/ui/motion/hooks.ts
import { useEffect, useState, type ReactNode, type RefObject } from 'react';
import { createPortal } from 'react-dom';
/**
* Keeps an element mounted while it animates out.
* `mounted` controls rendering, `visible` drives the enter/exit classes.
*/
export function usePresence(open: boolean, exitMs = 220) {
const [mounted, setMounted] = useState(open);
const [visible, setVisible] = useState(open);
useEffect(() => {
if (open) {
setMounted(true);
let inner = 0;
const outer = requestAnimationFrame(() => {
inner = requestAnimationFrame(() => setVisible(true));
});
return () => {
cancelAnimationFrame(outer);
cancelAnimationFrame(inner);
};
}
setVisible(false);
const t = window.setTimeout(() => setMounted(false), exitMs);
return () => window.clearTimeout(t);
}, [open, exitMs]);
return { mounted, visible };
}
/** Calls `handler` when Escape is pressed while `active`. */
export function useEscape(active: boolean, handler: () => void) {
useEffect(() => {
if (!active) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') handler();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [active, handler]);
}
/** Calls `handler` on pointerdown outside every provided ref while `active`. */
export function useClickOutside(
refs: Array<RefObject<HTMLElement>>,
active: boolean,
handler: () => void,
) {
useEffect(() => {
if (!active) return;
const onDown = (e: PointerEvent) => {
const target = e.target as Node;
if (refs.some((r) => r.current?.contains(target))) return;
handler();
};
document.addEventListener('pointerdown', onDown);
return () => document.removeEventListener('pointerdown', onDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, handler]);
}
/**
* Renders overlays into document.body so `position: fixed` is relative to the
* viewport even when an ancestor has a transform (e.g. entrance animations).
*/
export function Portal({ children }: { children: ReactNode }) {
if (typeof document === 'undefined') return null;
return createPortal(children, document.body);
}