Skip to content

Approval Card ​

Asks the user to approve an agent action, then collapses into a compact resolved row with a stamp that slams in.

Run a shell command

Medium risk

The agent wants to clear the build cache and rebuild the docs.

Command
rm -rf .vitepress/cache && pnpm build
Directory
~/code/vuelix/docs
Timeout
120 s

Send an email campaign

High risk

Announce the v2 launch to everyone on the newsletter list.

Recipients
12,408 subscribers
Subject
vuelix 2.0 is here
Send at
Immediately
Waiting for your decision…

Installation ​

pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/approval-card.json
Manual installation

Install the dependencies:

bash
npm install motion-v

Then copy these files into components/ui/approval-card/ (plus lib/utils.ts if you don't have it):

vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { AnimatePresence, motion, useDomRef, useReducedMotion } from 'motion-v'
import { computed, nextTick, useId } from 'vue'
import { cn } from '@/lib/utils'

export type ApprovalCardStatus = 'pending' | 'approved' | 'denied'
export type ApprovalCardRisk = 'low' | 'medium' | 'high'
export interface ApprovalCardDetail {
  label: string
  value: string
}

const props = withDefaults(defineProps<{
  /** What the agent wants to do, e.g. "Run a shell command". */
  title: string
  description?: string
  /** Key/value facts about the action — rendered as a definition list. */
  details?: ApprovalCardDetail[] | Record<string, string>
  risk?: ApprovalCardRisk
  approveLabel?: string
  denyLabel?: string
  class?: HTMLAttributes['class']
}>(), {
  risk: 'medium',
  approveLabel: 'Approve',
  denyLabel: 'Deny',
})

const status = defineModel<ApprovalCardStatus>('status', { default: 'pending' })
const emit = defineEmits<{
  approve: []
  deny: []
}>()

const uid = useId()
const reduced = useReducedMotion()
const resolvedEl = useDomRef()

const rows = computed<ApprovalCardDetail[]>(() => {
  const d = props.details
  if (!d)
    return []
  return Array.isArray(d) ? d : Object.entries(d).map(([label, value]) => ({ label, value }))
})

const RISK = {
  low: { label: 'Low risk', badge: 'bg-chart-1/15 text-chart-1', dot: 'bg-chart-1', icon: 'text-chart-1 bg-chart-1/10' },
  medium: { label: 'Medium risk', badge: 'bg-chart-4/15 text-chart-4', dot: 'bg-chart-4', icon: 'text-chart-4 bg-chart-4/10' },
  high: { label: 'High risk', badge: 'bg-destructive/15 text-destructive', dot: 'bg-destructive', icon: 'text-destructive bg-destructive/10' },
} as const

const layoutSpring = { type: 'spring', stiffness: 320, damping: 32 } as const
// A heavy, slightly under-damped spring: the stamp slams down and settles.
const stampSpring = { type: 'spring', stiffness: 700, damping: 18, mass: 1.1 } as const

function decide(next: 'approved' | 'denied') {
  status.value = next
  if (next === 'approved')
    emit('approve')
  else
    emit('deny')
  // The buttons are gone — move keyboard focus to the summary once it has mounted
  // (it waits for the pending view's exit animation).
  let tries = 0
  const focusSummary = () => {
    if (resolvedEl.value)
      resolvedEl.value.focus({ preventScroll: true })
    else if (tries++ < 25)
      setTimeout(focusSummary, 40)
  }
  nextTick(focusSummary)
}
</script>

<template>
  <motion.section
    layout
    :aria-labelledby="`${uid}-title`"
    :class="cn('w-full overflow-hidden border bg-card text-card-foreground shadow-sm', props.class)"
    :style="{ borderRadius: '16px' }"
    :transition="reduced ? { duration: 0 } : layoutSpring"
  >
    <AnimatePresence mode="wait" :initial="false">
      <motion.div
        v-if="status === 'pending'"
        key="pending"
        class="flex flex-col gap-4 p-4"
        :exit="reduced ? { opacity: 0 } : { opacity: 0, scale: 0.98, filter: 'blur(4px)' }"
        :transition="{ duration: 0.18 }"
      >
        <div class="flex items-start gap-3">
          <span :class="cn('grid size-9 shrink-0 place-items-center rounded-lg', RISK[props.risk].icon)" aria-hidden="true">
            <slot name="icon">
              <svg class="size-4.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3 4.5 6v5.5c0 4.6 3.1 8.3 7.5 9.5 4.4-1.2 7.5-4.9 7.5-9.5V6L12 3Z" /><path d="M12 8v4.5" /><path d="M12 16h.01" /></svg>
            </slot>
          </span>
          <div class="min-w-0 flex-1">
            <div class="flex flex-wrap items-center gap-x-2 gap-y-1">
              <h3 :id="`${uid}-title`" class="m-0 text-sm font-semibold leading-6">
                {{ props.title }}
              </h3>
              <span :class="cn('inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-medium', RISK[props.risk].badge)">
                <span :class="cn('size-1.5 rounded-full', RISK[props.risk].dot)" aria-hidden="true" />
                {{ RISK[props.risk].label }}
              </span>
            </div>
            <p v-if="props.description" class="m-0 mt-0.5 text-sm leading-5 text-muted-foreground">
              {{ props.description }}
            </p>
          </div>
        </div>

        <dl v-if="rows.length" class="m-0 grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1.5 rounded-lg border bg-muted/40 px-3 py-2.5 text-xs">
          <template v-for="row in rows" :key="row.label">
            <dt class="text-muted-foreground">
              {{ row.label }}
            </dt>
            <dd class="m-0 truncate font-mono text-foreground" :title="row.value">
              {{ row.value }}
            </dd>
          </template>
        </dl>
        <slot />

        <div class="flex items-center justify-end gap-2">
          <motion.button
            type="button"
            class="inline-flex h-9 items-center gap-1.5 rounded-lg border bg-background px-3.5 text-sm font-medium outline-none transition-colors hover:bg-muted focus-visible:ring-[3px] focus-visible:ring-ring/50"
            :while-press="{ scale: 0.95 }"
            @click="decide('denied')"
          >
            {{ props.denyLabel }}
          </motion.button>
          <motion.button
            type="button"
            class="inline-flex h-9 items-center gap-1.5 rounded-lg bg-primary px-3.5 text-sm font-medium text-primary-foreground shadow-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
            :while-press="{ scale: 0.95 }"
            @click="decide('approved')"
          >
            <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5 10 17 19 7" /></svg>
            {{ props.approveLabel }}
          </motion.button>
        </div>
      </motion.div>

      <motion.div
        v-else
        key="resolved"
        ref="resolvedEl"
        tabindex="-1"
        class="flex items-center gap-3 px-4 py-3 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-inset"
        :initial="{ opacity: 0 }"
        :animate="{ opacity: 1 }"
        :transition="{ duration: 0.2 }"
      >
        <span class="relative grid size-8 shrink-0 place-items-center" aria-hidden="true">
          <motion.span
            v-if="!reduced"
            :class="cn('absolute inset-0 rounded-full', status === 'approved' ? 'bg-brand/30' : 'bg-destructive/30')"
            :initial="{ scale: 0.6, opacity: 0.9 }"
            :animate="{ scale: 2, opacity: 0 }"
            :transition="{ duration: 0.6, delay: 0.12, ease: 'easeOut' }"
          />
          <motion.span
            :class="cn('relative grid size-8 place-items-center rounded-full text-background', status === 'approved' ? 'bg-brand' : 'bg-destructive')"
            :initial="reduced ? { opacity: 0 } : { scale: 0, rotate: -90 }"
            :animate="{ scale: 1, rotate: 0, opacity: 1 }"
            :transition="stampSpring"
          >
            <svg v-if="status === 'approved'" class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5 10 17 19 7" /></svg>
            <svg v-else class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12" /></svg>
          </motion.span>
        </span>

        <div class="min-w-0 flex-1">
          <p class="m-0 truncate text-sm font-medium leading-5">
            {{ props.title }}
          </p>
          <p class="m-0 text-xs leading-5 text-muted-foreground">
            <slot name="resolved" :status="status">
              {{ status === 'approved' ? 'You approved this action' : 'You denied this action' }}
            </slot>
          </p>
        </div>

        <motion.span
          :class="cn(
            'shrink-0 select-none rounded-md border-2 px-2 py-0.5 text-[11px] font-black uppercase tracking-[0.18em]',
            status === 'approved' ? 'border-brand text-brand' : 'border-destructive text-destructive',
          )"
          :initial="reduced ? { opacity: 0, rotate: -8 } : { opacity: 0, scale: 2.4, rotate: -24 }"
          :animate="{ opacity: 1, scale: 1, rotate: -8 }"
          :transition="{ ...stampSpring, delay: reduced ? 0 : 0.18 }"
        >
          {{ status === 'approved' ? 'Approved' : 'Denied' }}
        </motion.span>
      </motion.div>
    </AnimatePresence>
    <span class="sr-only" role="status">
      {{ status === 'pending' ? '' : `${props.title}: ${status}` }}
    </span>
  </motion.section>
</template>
ts
export { default as ApprovalCard } from './ApprovalCard.vue'
export type { ApprovalCardDetail, ApprovalCardRisk, ApprovalCardStatus } from './ApprovalCard.vue'

Usage ​

ts
import { ApprovalCard } from '@/components/ui/approval-card'
vue
<ApprovalCard
  v-model:status="status"
  title="Run a shell command"
  description="Clear the cache and rebuild."
  risk="medium"
  :details="{ Command: 'rm -rf .cache && pnpm build', Directory: '~/app' }"
  @approve="agent.resume()"
  @deny="agent.cancel()"
/>

After a decision the card fades its body, springs down to a one-line summary and stamps the verdict. Focus moves to the summary so keyboard users don't lose their place. Set status back to 'pending' to ask again.

API ​

PropTypeDefaultDescription
titlestring—What the agent wants to do.
descriptionstring—Why, in a sentence.
details{ label, value }[] | Record<string, string>—Facts about the action, shown as a key/value list.
risk'low' | 'medium' | 'high''medium'Badge and icon colour.
approveLabelstring'Approve'Approve button text.
denyLabelstring'Deny'Deny button text.
v-model:status'pending' | 'approved' | 'denied''pending'Decision state.
EventDescription
approveThe user approved.
denyThe user denied.
SlotDescription
defaultExtra content under the details (e.g. a code block or diff).
iconReplaces the shield icon.
resolvedText under the title once decided. Receives { status }.

Designed by Kuldeep Pisda · Released under the MIT License