UIPackage
Menu

Image Compare

image-compare ui
Edit on GitHub

Before/after image comparison slider with a draggable divider. Two images overlaid via clip-path, pointer-driven (mouse + touch), horizontal or vertical orientation, custom handle slot, labels, and disabled state.

Also available for React ->

Installation

$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/image-compare.json
Named registry: npx shadcn-vue@latest add @uipkge/image-compare Installs to: app/components/ui/image-compare/

Examples

Props

Name Type / Values Default Required
beforeSrc string required
afterSrc string required
beforeAlt string 'Before' optional
afterAlt string 'After' optional
beforeLabel string 'Before' optional
afterLabel string 'After' optional
modelValue number 50 optional
orientation
'horizontal''vertical'
'horizontal' optional
disabled boolean false optional
showLabels boolean true optional
showHandle boolean true optional
class HTMLAttributes['class'] optional

Files installed (3)

  • app/components/ui/image-compare/ImageCompare.vue 6.3 kB
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue'
    import { computed, onBeforeUnmount, ref, watch } from 'vue'
    import { MoveHorizontal, MoveVertical } from 'lucide-vue-next'
    import { cn } from '@/lib/utils'
    import { imageCompareVariants } from './image-compare.variants'
    
    interface Props {
      beforeSrc: string
      afterSrc: string
      beforeAlt?: string
      afterAlt?: string
      beforeLabel?: string
      afterLabel?: string
      modelValue?: number
      orientation?: 'horizontal' | 'vertical'
      disabled?: boolean
      showLabels?: boolean
      showHandle?: boolean
      class?: HTMLAttributes['class']
    }
    
    const props = withDefaults(defineProps<Props>(), {
      beforeAlt: 'Before',
      afterAlt: 'After',
      beforeLabel: 'Before',
      afterLabel: 'After',
      modelValue: 50,
      orientation: 'horizontal',
      disabled: false,
      showLabels: true,
      showHandle: true,
    })
    
    const emit = defineEmits<{
      'update:modelValue': [value: number]
    }>()
    
    const containerRef = ref<HTMLElement | null>(null)
    const handleRef = ref<HTMLElement | null>(null)
    const dragging = ref(false)
    const position = ref(props.modelValue)
    
    watch(
      () => props.modelValue,
      (v) => {
        if (v !== position.value) position.value = v
      },
    )
    
    function clamp(v: number): number {
      return Math.min(100, Math.max(0, v))
    }
    
    function updateFromPointer(clientX: number, clientY: number) {
      const el = containerRef.value
      if (!el) return
      const rect = el.getBoundingClientRect()
      if (props.orientation === 'horizontal') {
        position.value = clamp(((clientX - rect.left) / rect.width) * 100)
      } else {
        position.value = clamp(((clientY - rect.top) / rect.height) * 100)
      }
      emit('update:modelValue', position.value)
    }
    
    function onPointerDown(e: PointerEvent) {
      if (props.disabled) return
      dragging.value = true
      const el = e.currentTarget as HTMLElement
      try {
        el.setPointerCapture(e.pointerId)
      } catch {
        // pointer capture can fail on synthetic / non-active pointers
      }
      updateFromPointer(e.clientX, e.clientY)
    }
    
    function onPointerMove(e: PointerEvent) {
      if (!dragging.value || props.disabled) return
      updateFromPointer(e.clientX, e.clientY)
    }
    
    function onPointerUp(e: PointerEvent) {
      if (!dragging.value) return
      dragging.value = false
      const el = e.currentTarget as HTMLElement
      try {
        el.releasePointerCapture(e.pointerId)
      } catch {
        // pointer already released
      }
    }
    
    // Keyboard support: arrow keys move the slider by 1% (Shift = 10%)
    function onKeyDown(e: KeyboardEvent) {
      if (props.disabled) return
      const isH = props.orientation === 'horizontal'
      const step = e.shiftKey ? 10 : 1
      let next = position.value
      if (isH) {
        if (e.key === 'ArrowLeft') next -= step
        else if (e.key === 'ArrowRight') next += step
        else return
      } else {
        if (e.key === 'ArrowUp') next -= step
        else if (e.key === 'ArrowDown') next += step
        else return
      }
      e.preventDefault()
      position.value = clamp(next)
      emit('update:modelValue', position.value)
    }
    
    // "After" image is clipped to show only the right portion.
    // Dragging right (higher %) reveals more of "before" on the left.
    const clipStyle = computed(() => {
      const pct = position.value
      if (props.orientation === 'horizontal') {
        return { clipPath: `inset(0 0 0 ${pct}%)` }
      }
      return { clipPath: `inset(${pct}% 0 0 0)` }
    })
    
    const dividerStyle = computed(() => {
      const pct = position.value
      if (props.orientation === 'horizontal') {
        return { left: `${pct}%` }
      }
      return { top: `${pct}%` }
    })
    
    onBeforeUnmount(() => {
      dragging.value = false
    })
    </script>
    
    <template>
      <div
        ref="containerRef"
        data-uipkge
        data-slot="image-compare"
        :data-orientation="orientation"
        :data-disabled="disabled ? '' : undefined"
        :class="cn(imageCompareVariants({ orientation }), props.class)"
        style="touch-action: none"
        @pointerdown="onPointerDown"
        @pointermove="onPointerMove"
        @pointerup="onPointerUp"
        @pointercancel="onPointerUp"
      >
        <!-- Before (base layer, full) -->
        <img
          :src="beforeSrc"
          :alt="beforeAlt"
          class="pointer-events-none absolute inset-0 size-full object-cover select-none"
          draggable="false"
        />
        <span
          v-if="showLabels"
          class="bg-background/80 text-foreground absolute bottom-2 left-2 rounded px-2 py-0.5 text-xs font-medium backdrop-blur-sm"
        >
          {{ beforeLabel }}
        </span>
    
        <!-- After (clipped overlay — visible on the right side) -->
        <div class="absolute inset-0 size-full" :style="clipStyle">
          <img
            :src="afterSrc"
            :alt="afterAlt"
            class="pointer-events-none absolute inset-0 size-full object-cover select-none"
            draggable="false"
          />
          <span
            v-if="showLabels"
            class="bg-background/80 text-foreground absolute right-2 bottom-2 rounded px-2 py-0.5 text-xs font-medium backdrop-blur-sm"
          >
            {{ afterLabel }}
          </span>
        </div>
    
        <!-- Divider + handle -->
        <div
          v-if="!disabled"
          class="bg-border absolute z-10"
          :class="orientation === 'horizontal' ? 'top-0 h-full w-0.5' : 'left-0 h-0.5 w-full'"
          :style="dividerStyle"
        >
          <button
            v-if="showHandle"
            ref="handleRef"
            type="button"
            role="slider"
            :aria-valuenow="Math.round(position)"
            aria-valuemin="0"
            aria-valuemax="100"
            :aria-label="`Image comparison slider, ${Math.round(position)} percent`"
            :aria-orientation="orientation"
            tabindex="0"
            class="bg-background border-border focus-visible:ring-ring absolute flex size-9 cursor-ew-resize items-center justify-center rounded-full border shadow-md transition-transform hover:scale-110 focus-visible:ring-2 focus-visible:outline-none"
            :class="orientation === 'horizontal' ? 'left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2' : 'left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2'"
            :style="orientation === 'vertical' ? { cursor: 'ns-resize' } : {}"
            @keydown="onKeyDown"
            @pointerdown.stop="onPointerDown"
            @pointermove="onPointerMove"
            @pointerup="onPointerUp"
            @pointercancel="onPointerUp"
          >
            <slot name="handle">
              <MoveHorizontal v-if="orientation === 'horizontal'" class="text-foreground size-4" />
              <MoveVertical v-else class="text-foreground size-4" />
            </slot>
          </button>
        </div>
    
        <div v-if="disabled" class="bg-background/40 absolute inset-0" />
      </div>
    </template>
  • app/components/ui/image-compare/image-compare.variants.ts 0.5 kB
    import type { VariantProps } from 'class-variance-authority'
    import { cva } from 'class-variance-authority'
    
    export const imageCompareVariants = cva('bg-muted relative overflow-hidden rounded-lg border select-none', {
      variants: {
        orientation: {
          horizontal: 'cursor-ew-resize',
          vertical: 'cursor-ns-resize',
        },
      },
      defaultVariants: {
        orientation: 'horizontal',
      },
    })
    
    export type ImageCompareVariants = VariantProps<typeof imageCompareVariants>
  • app/components/ui/image-compare/index.ts 0.1 kB
    export { default as ImageCompare } from './ImageCompare.vue'
    export { imageCompareVariants, type ImageCompareVariants } from './image-compare.variants'

Raw manifest: https://uipkge.dev/r/vue/image-compare.json