Skip to content

Aspect Ratio ​

A fluid box that holds a ratio and springs smoothly to a new shape when the ratio changes.

Sunlit canyon walls
Video · 16:9

Installation ​

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

Install the dependencies:

bash
npm install motion-v

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

vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { motion, useReducedMotion, useSpring, useTransform } from 'motion-v'
import { watch } from 'vue'
import { cn } from '@/lib/utils'

const props = withDefaults(defineProps<{
  /** Width divided by height, e.g. `16 / 9`. Changing it animates the box. */
  ratio?: number
  /** Optional image rendered edge to edge inside the box. */
  src?: string
  alt?: string
  /** How the image fills the box (`object-fit`). */
  fit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down'
  /** Which part of the image stays visible when cropped (`object-position`). */
  position?: string
  class?: HTMLAttributes['class']
}>(), {
  ratio: 16 / 9,
  alt: '',
  fit: 'cover',
  position: 'center',
})

const reduced = useReducedMotion()

// The ratio itself is the animated value, so the box stays fluid in width
// and never needs to be measured.
const ratio = useSpring(props.ratio, { stiffness: 220, damping: 26, mass: 0.9 })
const aspectRatio = useTransform(ratio, r => `${Math.max(r, 0.01)}`)

watch(() => props.ratio, (next) => {
  if (reduced.value)
    ratio.jump(next)
  else
    ratio.set(next)
})
</script>

<template>
  <motion.div
    :class="cn('relative w-full overflow-hidden rounded-xl bg-muted', props.class)"
    :style="{ aspectRatio }"
  >
    <img
      v-if="props.src"
      :src="props.src"
      :alt="props.alt"
      class="absolute inset-0 size-full"
      :style="{ objectFit: props.fit, objectPosition: props.position }"
      draggable="false"
    >
    <div v-if="$slots.default" class="absolute inset-0">
      <slot />
    </div>
  </motion.div>
</template>
ts
export { default as AspectRatio } from './AspectRatio.vue'

Usage ​

ts
import { AspectRatio } from '@/components/ui/aspect-ratio'
vue
<AspectRatio :ratio="16 / 9" src="/cover.jpg" alt="Cover" position="top" />

<!-- or any content -->
<AspectRatio :ratio="1">
  <video src="/clip.mp4" class="size-full object-cover" autoplay muted loop />
</AspectRatio>

API ​

PropTypeDefaultDescription
rationumber16 / 9Width divided by height. Changing it springs the box to the new shape.
srcstring—Optional image rendered edge to edge.
altstring''Alt text for src.
fit'cover' | 'contain' | 'fill' | 'none' | 'scale-down''cover'object-fit for the image.
positionstring'center'object-position for the image, e.g. 'top' or '30% 70%'.

The default slot fills the box (absolutely positioned) and is layered over the image, so it doubles as an overlay. The ratio itself is the animated value, so the box stays fluid in width and is never measured; with prefers-reduced-motion it snaps.

Designed by Kuldeep Pisda · Released under the MIT License