navui
componentapproval-panelsource: ok · hit
Approval Panel Illustration
An approval routing panel illustration.
npx shadcn add "https://ui.navdeepsingh.dev/r/approval-panel.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumiconslucide-react
animationmotion
Files2 · source cached 10/8/2026, 8:13:29 PM
src/registry/illustrations/expense-workflow/expense-workflow-parts.tsx
'use client'
import type { ReactNode } from 'react'
import { ArrowRight, Coffee, Mail, NfcIcon, User } from 'lucide-react'
import {
motion,
type Variants,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
} from 'motion/react'
import { cn } from '@/lib/utils'
const ENTRANCE_EASE: [number, number, number, number] = [0.22, 1, 0.36, 1]
const PANEL_ENTRANCE_START = 0.62
const PANEL_STAGGER = 0.12
const cardEntranceVariants: Variants = {
hidden: {
opacity: 0,
y: 52,
scale: 0.97,
},
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: {
delay: 0.05,
duration: 0.72,
ease: ENTRANCE_EASE,
},
},
}
const panelEntranceVariants: Variants = {
hidden: {
opacity: 0,
y: 32,
scale: 0.97,
filter: 'blur(12px)',
},
visible: (order: number = 0) => ({
opacity: 1,
y: 0,
scale: 1,
filter: 'blur(0px)',
transition: {
delay: PANEL_ENTRANCE_START + order * PANEL_STAGGER,
duration: 0.64,
ease: ENTRANCE_EASE,
},
}),
}
export type WorkflowStage = 'receipt' | 'policy' | 'category' | 'approval'
type WorkflowPanelProps = {
children: ReactNode
className?: string
isActive: boolean
order: number
shouldReduceMotion: boolean
stage: WorkflowStage
title: string
}
export function WorkflowPanel({
children,
className,
isActive,
order,
shouldReduceMotion,
stage,
title,
}: WorkflowPanelProps) {
return (
<motion.div
variants={panelEntranceVariants}
custom={order}
initial={shouldReduceMotion ? false : 'hidden'}
animate={shouldReduceMotion ? undefined : isActive ? 'visible' : 'hidden'}
whileHover={
shouldReduceMotion
? undefined
: {
y: -5,
transition: {
duration: 0.22,
ease: 'easeOut',
},
}
}
style={{
willChange: shouldReduceMotion ? undefined : 'transform, opacity, filter',
}}
className={cn(
'absolute transform-gpu border border-border/70',
'bg-background/95 p-2.5 text-foreground backdrop-blur-[2px]',
'shadow-[0_12px_34px_rgba(15,23,42,0.07)]',
'transition-shadow duration-300',
'hover:shadow-[0_18px_42px_rgba(15,23,42,0.1)]',
'sm:p-4',
'dark:border-white/10 dark:bg-zinc-950/95',
'dark:shadow-[0_14px_36px_rgba(0,0,0,0.34)]',
'dark:hover:shadow-[0_20px_48px_rgba(0,0,0,0.48)]',
className,
)}
data-workflow-panel={stage}
>
<PanelCornerMarkers />
<p className="text-[8px] font-semibold tracking-wide text-muted-foreground uppercase sm:text-[10px]">
{title}
</p>
{children}
</motion.div>
)
}
function PanelCornerMarkers() {
const positions = [
'-top-1 -left-1',
'-top-1 -right-1',
'-bottom-1 -left-1',
'-bottom-1 -right-1',
] as const
return (
<div aria-hidden="true" className="pointer-events-none">
{positions.map((position) => (
<span
key={position}
className={cn('absolute size-1.5 rounded-full bg-muted-foreground', position)}
/>
))}
</div>
)
}
function PanelInset({ children, className }: { children: ReactNode; className?: string }) {
return (
<div
className={cn(
'mt-2.5 rounded-sm border border-border/60 bg-muted/80 p-2',
'sm:mt-3 sm:p-2.5',
className,
)}
>
{children}
</div>
)
}
export function PaymentCard({
isActive,
shouldReduceMotion,
}: {
isActive: boolean
shouldReduceMotion: boolean
}) {
const pointerX = useMotionValue(0)
const pointerY = useMotionValue(0)
const springX = useSpring(pointerX, {
stiffness: 160,
damping: 22,
mass: 0.4,
})
const springY = useSpring(pointerY, {
stiffness: 160,
damping: 22,
mass: 0.4,
})
const rotateY = useTransform(springX, [-1, 1], [-12, 12])
const rotateX = useTransform(springY, [-1, 1], [12, -12])
const resetTilt = () => {
pointerX.set(0)
pointerY.set(0)
}
return (
<div
className={cn(
'absolute top-1/2 left-1/2 z-30',
'h-60 w-38 -translate-x-1/2 -translate-y-1/2',
'sm:h-72 sm:w-46',
'lg:h-82 lg:w-52',
)}
>
<motion.div
variants={cardEntranceVariants}
initial={shouldReduceMotion ? false : 'hidden'}
animate={shouldReduceMotion ? undefined : isActive ? 'visible' : 'hidden'}
style={{
willChange: shouldReduceMotion ? undefined : 'transform, opacity',
}}
className="size-full transform-gpu"
>
<motion.div
onPointerMove={(event) => {
const hasFinePointer = window.matchMedia('(hover: hover) and (pointer: fine)').matches
if (shouldReduceMotion || !hasFinePointer) {
return
}
const rect = event.currentTarget.getBoundingClientRect()
pointerX.set(((event.clientX - rect.left) / rect.width - 0.5) * 2)
pointerY.set(((event.clientY - rect.top) / rect.height - 0.5) * 2)
}}
onPointerLeave={resetTilt}
onPointerCancel={resetTilt}
style={{
rotateX: shouldReduceMotion ? 0 : rotateX,
rotateY: shouldReduceMotion ? 0 : rotateY,
transformPerspective: 750,
transformStyle: 'preserve-3d',
}}
className={cn(
'relative size-full transform-gpu overflow-hidden',
'rounded-[1.2rem] border border-white/10 bg-zinc-950',
'shadow-[0_28px_70px_rgba(15,23,42,0.2),0_10px_24px_rgba(15,23,42,0.14)]',
'sm:rounded-[1.35rem]',
'dark:shadow-[0_30px_80px_rgba(0,0,0,0.52)]',
)}
>
<CardDecoration />
<CardChip />
<div className="absolute top-5 left-5 z-10 flex items-center gap-1 text-white sm:top-7 sm:left-6">
<NfcIcon aria-hidden="true" className="size-4 sm:size-5" />
<span className="text-[10px] font-semibold tracking-wide sm:text-xs">Payflow</span>
</div>
<div className="absolute bottom-5 left-5 z-10 text-[8px] font-medium tracking-[0.16em] text-white/50 uppercase sm:bottom-6 sm:left-6 sm:text-[10px] sm:tracking-[0.18em]">
4821 {'\u2022\u2022\u2022\u2022'} 8830
</div>
</motion.div>
</motion.div>
</div>
)
}
function CardDecoration() {
return (
<>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]"
>
{Array.from({ length: 16 }).map((_, index) => (
<span
key={index}
className="absolute rounded-full border border-white/10"
style={{
right: `${index * 9 - 48}px`,
bottom: `${index * -2 - 58}px`,
width: `${160 + index * 22}px`,
height: `${120 + index * 22}px`,
}}
/>
))}
</div>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-20 rounded-[inherit] border border-white/5"
/>
</>
)
}
function CardChip() {
return (
<div
aria-hidden="true"
className={cn(
'absolute top-5 right-5 z-10 aspect-square w-8 overflow-hidden',
'rounded-[22%] bg-[#9d9580]',
'shadow-[0_5px_12px_rgba(0,0,0,0.3),inset_1px_1px_2px_rgba(255,255,255,0.45),inset_-1px_-1px_2px_rgba(47,43,34,0.28)]',
'sm:top-6 sm:right-6 sm:w-9',
)}
>
<div className="relative z-10 grid size-full grid-cols-3 grid-rows-3 overflow-hidden rounded-[15%]">
<div className="col-span-2 border-r border-b border-neutral-500/60" />
<div className="border-b border-neutral-500/60" />
<div className="border-r border-b border-neutral-500/60" />
<div className="col-span-2 border-b border-neutral-500/60" />
<div className="col-span-2 border-r border-neutral-500/60" />
</div>
</div>
)
}
const receiptItems = [
{
name: '1 \u00d7 Coffee',
price: '$2.30',
},
{
name: '1 \u00d7 Classic Donut',
price: '$3.59',
},
] as const
function ReceiptItem({ name, price }: { name: string; price: string }) {
return (
<div className="flex w-full items-center justify-between gap-3">
<p>{name}</p>
<p>{price}</p>
</div>
)
}
export function ReceiptPanelContent() {
return (
<PanelInset>
<div className="flex flex-col items-center">
<div className="flex w-full flex-col items-center gap-2 border-b border-dashed border-border pb-4">
<Coffee aria-hidden="true" className="size-4 text-muted-foreground" />
<div className="text-center text-[10px] leading-3 tracking-tight text-muted-foreground">
<p>CAFE</p>
<p>123 Roadhouse Ave</p>
<p>M9A 4XZ, CA</p>
</div>
</div>
<div className="flex w-full flex-col gap-2 pt-4 font-mono text-[9px] tracking-tight">
{receiptItems.map((item) => (
<ReceiptItem key={item.name} name={item.name} price={item.price} />
))}
<div className="border-t border-muted-foreground/60 pt-1">
<div className="flex justify-between font-semibold text-foreground">
<p>TOTAL</p>
<p>$5.89</p>
</div>
</div>
</div>
</div>
</PanelInset>
)
}
export function MatchingPanelContent({
isActive,
shouldReduceMotion,
}: {
isActive: boolean
shouldReduceMotion: boolean
}) {
return (
<PanelInset>
<div className="flex flex-col gap-2 text-[7px] sm:text-[8px]">
<ProgressRow label="Office supplies" width="42%" />
<div className="flex flex-col gap-1">
<p>Business travel</p>
<div className="relative h-1 overflow-hidden rounded-full bg-border">
<motion.span
initial={false}
animate={{
width: shouldReduceMotion || isActive ? '60%' : '2%',
}}
transition={
shouldReduceMotion
? { duration: 0 }
: {
delay: PANEL_ENTRANCE_START + PANEL_STAGGER + 0.24,
duration: 0.65,
ease: ENTRANCE_EASE,
}
}
className="absolute inset-y-0 left-0 rounded-full bg-blue-500/80"
/>
</div>
</div>
<ProgressRow label="WFH stipend" width="52%" />
</div>
</PanelInset>
)
}
function ProgressRow({ label, width }: { label: string; width: string }) {
return (
<div className="flex flex-col gap-1">
<p>{label}</p>
<div className="relative h-1 overflow-hidden rounded-full bg-border">
<span
className="absolute inset-y-0 left-0 rounded-full bg-muted-foreground/30"
style={{ width }}
/>
</div>
</div>
)
}
const reportItems = [
{
label: 'Action required',
highlighted: false,
},
{
label: 'Expense report 1084',
highlighted: true,
},
{
label: 'Missing receipt',
highlighted: false,
},
{
label: 'Report 1280',
highlighted: false,
},
] as const
export function ReportPanelContent() {
return (
<div className="mt-3 space-y-2.5 sm:mt-4 sm:space-y-3">
{reportItems.map((item) => (
<div
key={item.label}
className={cn(
'flex min-w-0 items-center gap-2',
item.highlighted ? 'text-foreground' : 'text-muted-foreground/35',
)}
>
<Mail aria-hidden="true" className="size-4 shrink-0 sm:size-4.5" />
<p
className={cn('truncate text-[9px] sm:text-[11px]', item.highlighted && 'font-medium')}
>
{item.label}
</p>
</div>
))}
</div>
)
}
export function ApprovalPanelContent() {
return (
<PanelInset>
<div className="flex items-center gap-2 sm:gap-3">
<div className="grid size-8 shrink-0 place-items-center rounded-full bg-zinc-900 text-white sm:size-9 dark:bg-white dark:text-zinc-950">
<User aria-hidden="true" className="size-3.5 sm:size-4" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-[10px] font-semibold uppercase sm:text-xs">John Doe</p>
<p className="mt-0.5 truncate text-[8px] text-muted-foreground sm:text-[10px]">
Finance Manager
</p>
</div>
<div
aria-hidden="true"
className="grid size-7 shrink-0 place-items-center rounded-full border border-border/60 bg-background sm:size-8"
>
<ArrowRight className="size-3.5 text-muted-foreground sm:size-4" />
</div>
</div>
</PanelInset>
)
}
export function StandalonePanelFrame({
children,
className,
title,
stage,
}: {
children: ReactNode
className?: string
title: string
stage: WorkflowStage
}) {
const shouldReduceMotion = Boolean(useReducedMotion())
return (
<div
role="img"
aria-label={title}
className={cn('relative h-52 w-64 text-left sm:h-58 sm:w-72', className)}
>
<WorkflowPanel
stage={stage}
title={title}
order={0}
isActive={true}
shouldReduceMotion={shouldReduceMotion}
className="inset-0 w-full"
>
{children}
</WorkflowPanel>
</div>
)
}
src/registry/illustrations/approval-panel/approval-panel.tsx
'use client'
import {
ApprovalPanelContent,
StandalonePanelFrame,
} from '@/components/illustrations/_expense-workflow-parts'
type ApprovalPanelIllustrationProps = {
className?: string
}
export function ApprovalPanelIllustration({ className }: ApprovalPanelIllustrationProps) {
return (
<StandalonePanelFrame title="Approval routed" stage="approval" className={className}>
<ApprovalPanelContent />
</StandalonePanelFrame>
)
}
export default ApprovalPanelIllustration