DEV Community

Misha Grebennikov
Misha Grebennikov

Posted on

Resize Bounding: Making Vue3 Components Truly Resizable ✨

The Challenge

When designing interfaces, I often ran into a common problem: users needed to resize elements freely, but most solutions felt clunky or rigid. I wanted something intuitive, smooth, and predictable. That’s how Resize Bounding came to life.

Why I Built It 💡

Users struggle when components ignore container boundaries.
Existing Vue3 solutions were either heavy or inflexible.
I needed a lightweight, reusable tool that I could integrate into my projects — and share with the community.

The Approach

I focused on Vue3 Composition API and pointer events to make resizing seamless across devices. Key design choices:

Reactive boundaries — Automatically calculate max/min width and height to respect parent constraints.
Customizable handles — Style them or hide them, depending on the UX.
Installation:

npm i vue3-resize-bounding

# or

yarn add vue3-resize-bounding
Enter fullscreen mode Exit fullscreen mode

Examples (Vue.js)

The :directions property enables the necessary borders for resizing. The literal ‘hv’ specifies which boundaries should be enabled for resizing. The order of the characters is not significant. 'hv'is equivalent (alias) to 'tblr'

't' — top; 'r'— right; 'b'— bottom; 'l'-left; 'h'-horizontal alias, equivalent to 'lr' ; 'v'-vertical alias, equivalent to 'tb'

Simple Usage:

<!-- @filename: MyComponent.vue -->
<script setup lang="ts">
  import { ref } from "vue";
  import ResizeBounding from "vue3-resize-bounding";

  const width = ref(320);
  const height = ref(480);
</script>

<template>
  <ResizeBounding v-model:width="width" v-model:height="height" directions="hv" :min-width="100" :max-width="400">
    <div class="resizable-box">Resizable Content</div>
  </ResizeBounding>
</template>
Enter fullscreen mode Exit fullscreen mode

Custom setup:

<!-- @filename: MyComponent.vue -->
<script setup lang="ts">
  import { ref } from "vue";
  import ResizeBounding from "vue3-resize-bounding";

  const container = ref({ width: 320, height: 480 });
</script>

<template>
  <ResizeBounding
    :width="container.width"
    :height="container.height"
    :min-width="240"
    :max-width="480"
    :min-height="120"
    :directions="'hv'"
    :options="{
        position: 'central',
        splitterWidthNormal: 1,
        splitterWidthActive: 4,
        knob: {
          show: true
        }
    }"
    :style="{ border: '1px solid gray' }"
    @update:width="(width) => (container.width = width)"
    @update:height="(height) => (container.height = height)"
  >
    <!-- CONTENT START -->
    <div :style="{ width: '100%', height: '100%' }">My Container</div>
    <!-- CONTENT END -->

    <!-- KNOB INNER CONTENT START -->
    <template #knob>
      <div class="some-icon"></div>
    </template>
    <!-- KNOB INNER CONTENT END -->
  </ResizeBounding>
</template>
Enter fullscreen mode Exit fullscreen mode

Styling of a component can be done by directly modifying the CSS properties of global classes, or by using the styles object, which uses the [@fluentui/merge-styles](https://www.npmjs.com/package/@fluentui/merge-styles) syntax.

Using SCSS:

<style lang="scss">
  $prefix: "resize-bounding-";

  .#{$prefix} {
    &-container {
    }
    &-pane {
      /* Normal state */
      .#{$prefix}splitter {
        &--container {
        }
      }
      .#{$prefix}knob {
      }

      /* * * Default `options` settings * * */

      /* Both selected and pressed states */
      &.active {
        .#{$prefix}splitter {
        }
        .#{$prefix}knob {
        }
      }

      /* * * Separate states ({ addStateClasses: true }) * * */

      /* Normal state */
      &.normal {
        .#{$prefix}splitter {
        }
        .#{$prefix}knob {
        }
      }

      /* Focused state */
      &.focused {
        .#{$prefix}splitter {
        }
        .#{$prefix}knob {
        }
      }

      /* Pressed state */
      &.pressed {
        .#{$prefix}splitter {
        }
        .#{$prefix}knob {
        }
      }
    }
  }
</style>
Enter fullscreen mode Exit fullscreen mode

Using ‘styles’ object:

<!-- @filename: MyResizeBoundingComponent.vue -->

<script lang="ts">
  import ResizeBounding, { PREFIX } from "vue3-resize-bounding";

  /* * * Default styles and classes * * */

  const options = {
    width: 4,
    activeAreaWidth: undefined,
    position: "central", // 'central' | 'internal' | 'external'
    knob: {
      show: true,
      normalHidden: true,
    },
    cursor: {
      horizontal: "col-resize",
    },
    touchActions: true,
  };

  // Below are all the default styles purely for demonstration purposes
  // In reality, you can only override the necessary properties
  const styles = (prefix: string): IStyles => ({
    container: [
      globalClassNames(prefix).container,
      { displayName: globalClassNames(prefix).container, position: "relative" },
    ],
    pane: [
      globalClassNames(prefix).pane,
      {
        displayName: globalClassNames(prefix).pane,
        position: "absolute",
        display: "block",
        zIndex: 9999,
        touchAction: "none",
      },
    ],
    splitter: [
      globalClassNames(prefix).splitter,
      {
        displayName: globalClassNames(prefix).splitter,
        position: "absolute",
        zIndex: 9999,
        transition: "background 125ms ease-out",
        [`.${globalClassNames(prefix).pane}.active &`]: {
          background: "cornflowerblue",
        },
        /* 
        Focused state:
        [`.${globalClassNames(prefix).pane}.focused &`]: {},
        Pressed state:
        [`.${globalClassNames(prefix).pane}.pressed &`]: {}
        */
      },
    ],
    splitterContainer: [
      globalClassNames(prefix).splitterContainer,
      {
        displayName: globalClassNames(prefix).splitterContainer,
        position: "relative",
        top: "50%",
        left: "50%",
        width: `0px`,
        height: `0px`,
      },
    ],
    knob: [
      globalClassNames(prefix).knob,
      {
        displayName: globalClassNames(prefix).knob,
        position: "relative",
        width: "64px",
        height: "6px",
        background: "gray",
        borderRadius: "3px",
        transform: "translate(-50%, -50%)",
        transition: "background 125ms ease-out",
        [`.${globalClassNames(prefix).pane}.active &`]: {
          background: "cornflowerblue",
        },
        /* 
        Focused state:
        [`.${globalClassNames(prefix).pane}.focused &`]: {},
        Pressed state:
        [`.${globalClassNames(prefix).pane}.pressed &`]: {}
        */
      },
    ],
  });
</script>
Enter fullscreen mode Exit fullscreen mode

Detailed documentation on properties and their types is available in the README

For a clearer understanding, you can explore the Storybook documentation, where all properties are showcased with interactive examples.

What Worked ✅

Resizing feels natural and responsive, even for nested components.
Minimal code footprint, easy to integrate for developers.
Testing confirmed smooth and predictable behavior.

Lessons Learned

Handling nested elements and offsets is tricky — reactive calculations are essential.
Cross-device testing revealed subtle quirks in pointer events and CSS transitions.
Clear documentation and examples save developers’ time and frustration.

Takeaway

Resize Bounding is now a reusable Vue3 component that solves a real UX problem while remaining lightweight and flexible. It demonstrates my approach: balancing UX, performance, and developer usability.

💡 See it in action: GitHub Repo

Live Demo: resize-bounding.netlify.app
Vue3 Documentation: vue3-resize-bounding-docs.netlify.app
React Documentation: react-resize-bounding-docs.netlify.app

Top comments (0)