shadimage
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 · medium
iconslucide-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

More from navui

Browse all components in this library →