Skip to content

Draggable summary-details bottom drawer

A draggable bottom drawer/card with two open positions - half and full (summary and details).

vuequasarcss
by
PDan

Problem description

You need a drawer/card on bottom that has two states:

  • half open, to show main settings or preview - summary
  • full open, to show all settings of details - details

Solution

This is an example of how to build a custom component bottom drawer.

You can expand it by dragging or by clicking on the pill.

On clicking on the pill the drawer will cycle between half open, full open, closed;

When dragging, on release the drawer will snap to the closest position.

The component exposes three slots:

  • #pill
  • #summary
  • #details

All three slots have access in slotProps to:

  • cycleMode method
  • setMode method to directly change the mode
  • mode value - current mode, one of half, full, handler
vue
<template>
  <div class="q-pa-md">
    <div v-for="i in 5" class="text-subtitle1 q-py-xl text-center">
      Play with the drawer on bottom
    </div>

    <bottom-drawer>
      <template #pill="{ cycleMode, mode }">
        <span class="text-caption">[{{ mode }}] Drag drawer</span>
        <div class="cursor-pointer" @click="cycleMode"></div>
        <span class="text-caption">or click pill</span>
      </template>

      <template #summary>
        <div class="text-h6">Our Changing Planet</div>
        <div class="text-subtitle2">by John Doe</div>
        <div>{{ lorem }}</div>
      </template>

      <template #details>
        <div class="text-h6">Our Changing Planet - only shown when drawer is open</div>
        <div class="text-subtitle2">by John Doe</div>
        <div>{{ lorem }}</div>
      </template>
    </bottom-drawer>
  </div>
</template>

<style lang="sass">
.slide-drawer
  &--bottom
    border-bottom-left-radius: 0
    border-bottom-right-radius: 0
    background-color: #333
    background-image: radial-gradient(circle, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.4) 100%)
    bottom: unset
    top: 100%
    transition: background-color 0.3s ease-in-out

    > div:last-child,
    > img:last-child
      border-bottom-left-radius: 0
      border-bottom-right-radius: 0

    &.slide-drawer--open-half
      background-color: #014a88

    &.slide-drawer--open-full
      background-color: #01884a

  &__handler
    &--horizontal
      cursor: grab

      > div
        width: 60px
        height: 8px
        border-radius: 4px
        background-color: rgba(200, 200, 200, 0.7)

</style>

<script setup lang="ts">
import BottomDrawer from './BottomDrawer.vue';

const lorem = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.';
</script>
vue
<template>
  <q-card
    class="slide-drawer slide-drawer--bottom text-white fixed-bottom column no-wrap"
    :class="`slide-drawer--open-${drawerMode}`"
    :style="drawerStyle"
  >
    <q-card-section class="slide-drawer__handler--horizontal row flex-center q-pa-sm q-gutter-x-md" v-touch-pan.mouse.vertical.prevent="slideDrawer">
      <slot name="pill" :cycle-mode="cycleDrawer" :mode="drawerMode" :set-mode="setMode"></slot>
    </q-card-section>

    <q-card-section class="col">
      <slot name="summary" :cycle-mode="cycleDrawer" :mode="drawerMode" :set-mode="setMode"></slot>
    </q-card-section>

    <q-card-section v-if="drawerMode !== 'handler'" class="col">
      <slot name="details" :cycle-mode="cycleDrawer" :mode="drawerMode" :set-mode="setMode"></slot>
    </q-card-section>
  </q-card>
</template>

<script setup lang="ts">
import { ref, computed, nextTick, onBeforeUnmount } from 'vue';
import { useQuasar } from 'quasar';

const props = defineProps({
  drawerMinHeight: {
    type: Number,
    default: 36,
  },
  drawerTopOffset: {
    type: Number,
    default: 100,
  },
  drawerOpenRatioHalf: {
    type: Number,
    default: 50,
  },
});

let animateTimeout = null;

const $q = useQuasar();

const drawerPos = ref(props.drawerMinHeight);

const drawerMaxHeight = computed(() => Math.max(0, $q.screen.height - props.drawerTopOffset));

const drawerOpenRatio = computed(() => Math.round((Math.max(0, drawerPos.value - props.drawerMinHeight) * 100) / Math.max(1, drawerMaxHeight.value - props.drawerMinHeight)));

const drawerStyle = computed(() => ({
  height: `${ drawerMaxHeight.value }px`,
  transform: `translateY(${ -drawerPos.value }px)`,
}));

const drawerMode = computed(() => {
  if (drawerOpenRatio.value > props.drawerOpenRatioHalf) {
    return 'full';
  }

  return drawerOpenRatio.value > 0
    ? 'half'
    : 'handler';
});

function animateDrawerTo (height) {
  if (animateTimeout !== null) {
    clearTimeout(animateTimeout);
  }
  animateTimeout = null;

  const diff = height - drawerPos.value;

  if (diff !== 0) {
    drawerPos.value += Math.abs(diff) < 2 ? diff : Math.round(diff / 2);

    animateTimeout = setTimeout(() => {
      animateDrawerTo(height);
    }, 30);
  }
}

function slideDrawer (ev) {
  const { direction, delta, isFinal } = ev;

  drawerPos.value = Math.max(props.drawerMinHeight, Math.min(drawerMaxHeight.value, drawerPos.value - delta.y));

  if (isFinal === true) {
    nextTick(() => {
      const aboveHalf = drawerOpenRatio.value > props.drawerOpenRatioHalf;
      // eslint-disable-next-line no-nested-ternary
      const targetHeight = direction === 'up'
        ? (aboveHalf ? drawerMaxHeight.value : Math.round(drawerMaxHeight.value / 2))
        : (aboveHalf ? Math.round(drawerMaxHeight.value / 2) : props.drawerMinHeight);

      animateDrawerTo(targetHeight);
    });
  }
}

function cycleDrawer () {
  // eslint-disable-next-line no-nested-ternary
  const targetHeight = drawerMode.value === 'handler'
    ? Math.round(drawerMaxHeight.value / 2)
    : (drawerMode.value === 'half' ? drawerMaxHeight.value : props.drawerMinHeight);

  animateDrawerTo(targetHeight);
}

function setMode(mode: 'half' | 'full' | 'handler') {
  if (['half', 'full', 'handler'].includes(mode)) {
    // eslint-disable-next-line no-nested-ternary
    const targetHeight = mode === 'half'
      ? Math.round(drawerMaxHeight.value / 2)
      : (drawerMode.value === 'full' ? drawerMaxHeight.value : props.drawerMinHeight);

    animateDrawerTo(targetHeight);
  }
}

onBeforeUnmount(() => {
  if (animateTimeout !== null) {
    clearTimeout(animateTimeout);
  }
});
</script>

Demo

Last updated: