Edit: I've built this on codesandbox. Some of the implementations aren't working for whatever reason (it doesn't like my img src routes)
So I am trying to build a responsive navbar that has a couple custom dropdowns. Intitally I started with making the dropdown options absolute to the parent element, but that would make it so that I need space between both dropdowns so that they don't overflow/cover each other.
I have them closer together now and the options are no longer relative, but as you can tell from the gif, the language change dropdown now jumps to make space for the options. Shifting up is totally fine and really the only option, but is there some sort of animation or transition that I can use to make it not so jittery/ more smooth?
Any tips or ideas would be greatly appreciated!
Cheers!
LangDropdown.vue
<template>
<div class="_custom-select" @blur="dropdownIsOpen = false">
<div
style="display: flex; flex-direction: column; justify-content: space-between"
@click="dropdownIsOpen = !dropdownIsOpen"
>
<div style="display: flex">
<img style="width: 34px" class="_icon" src="../assets/languages-icon.svg" alt="Change Language Icon" />
<div style="flex: 1; display: flex; justify-content: space-between">
<div v-if="!collapsed" class="_selected-option">
<div>
{{ selected }}
</div>
<img class="_icon" src="../assets/chevron-down.svg" alt="" />
</div>
</div>
</div>
<transition name="slide">
<ul v-if="dropdownIsOpen && !collapsed" class="_options">
<li v-for="(option, i) of options" :key="i" @click="selectOption(option)">
{{ option }}
</li>
</ul>
</transition>
</div>
</div>
</template>
<script lang="ts" setup>
import { PropType, ref } from "vue"
import { collapsed } from "./state"
const props = defineProps({
options: { type: Array as PropType<string[]>, required: true },
default: { type: String, required: true }
})
const emit = defineEmits(["input"])
const selected = ref(props.default ? props.default : props.options.length > 0 ? props.options[0] : null)
const dropdownIsOpen = ref(true)
if (collapsed) dropdownIsOpen.value = false
function selectOption(_option: any) {
selected.value = _option
dropdownIsOpen.value = false
emit("input", _option)
}
</script>
<style lang="sass">
._custom-select
position: relative
width: 100%
text-align: left
outline: none
font-size: 16px
border-radius: 6px
&:hover
._icon,
._selected-option
background-color: var(--sidebar-item-hover)
._selected-option
flex: 1
display: flex
justify-content: space-between
margin-left: 1em
text-align: left
border-radius: 6px
padding: 8px 18px
cursor: pointer
user-select: none
line-height: 26px
._icon
width: 24px
border-radius: 6px
cursor: pointer
._options
// position: absolute
// right: 0
// top: 100%
margin: 0
margin-left: auto
padding: 8px
padding-top: 0
list-style-type: none
transform-origin: top
transition: transform 300ms ease-in-out
overflow: hidden
> *
border-radius: 6px
text-align: left
cursor: pointer
user-select: none
padding: 6px
width: 100%
> *:hover
background-color: var( --sidebar-item-hover)
.slide-move,
.slide-enter-from,
.slide-leave-to
transform: scaleY(0)
._custom-select ._options div:hover
background-color: var( --sidebar-item-hover)
</style>
Sidebar.vue
<!-- eslint-disable vue/multi-word-component-names -->
<script lang="ts" setup>
import SidebarLink from "./SidebarLink.vue"
import { collapsed, toggleSidebar, sidebarWidth } from "./state"
import LangDropdown from "./LangDropdown.vue"
import MyAccountDropDown from "./MyAccountDropDown.vue"
const emit = defineEmits(["change"])
function changeLang(lang: any) {
emit("change", lang)
}
</script>
<template>
<div class="_sidebar" :style="{ width: sidebarWidth }">
<div class="_collapse-icon" :class="{ '_rotate-180': collapsed }" @click="toggleSidebar">
<img src="../assets/chevron-left.svg" alt="Collapse Sidebar" />
</div>
<router-link style="text-decoration: none" to="/">
<div class="_home-link">
<img style="width: 34px" src="../assets/logo.svg" alt="" />
<div v-if="!collapsed" class="_home-link-text">Home</div>
</div>
</router-link>
<div class="_sidebar-links">
<SidebarLink to="/videos" icon="videos-icon" label="Videos" />
<SidebarLink to="/annotator" icon="annotator-icon" label="Annotator" />
<SidebarLink to="/training" icon="training-icon" label="Training" />
<SidebarLink to="/inference" icon="inference-icon" label="Inference" />
<SidebarLink to="/work-insights" icon="work-insights-icon" label="Work Insights" />
</div>
<div class="_dropdowns">
<LangDropdown
tabindex="0"
:options="['English', 'Simplified Chinese', 'Traditional Chinese']"
:default="'English'"
@input="changeLang"
/>
<MyAccountDropDown tabindex="0" />
</div>
</div>
</template>
<style lang="sass">
\:root
--sidebar-bg-color: #4272ce
--sidebar-item-hover: #5489ef
--sidebar-item-active: #5489ef
</style>
<style lang="sass" scoped>
._sidebar
position: relative
display: flex
flex-direction: column
height: 100vh
width: auto
padding: 0.5em
color: white
background-color: var(--sidebar-bg-color)
transition: 0.3s ease
z-index: 1
&::after
content: ''
position: absolute
top: 0
bottom: 0
left: 0
right: 0
width: 50px
height: 100%
display: block
background-color: #2d5ab2
z-index: -1
._sidebar-links > *:not(:last-child)
margin-bottom: 2em
._collapse-icon
position: absolute
top: 4em
right: -12px
display: inline-block
background-color: #4b4bd9
width: 1.5em
height: 1.5em
border: 0.25em solid #4b4bd9
border-radius: 50%
text-align: center
cursor: pointer
._home-link
position: relative
display: flex
align-items: center
cursor: pointer
user-select: none
margin-top: 1em
margin-bottom: 4em
border-radius: 0.25em
height: 1.5em
color: white
&-text
flex: 1
display: flex
margin-left: 2rem
text-align: left
text-decoration: none
font-size: 18px
._rotate-180
transform: rotate(180deg)
transition: 0.3s linear
._dropdowns
margin-top: auto
margin-bottom: 8em
> *:first-child
margin-bottom: 2em
</style>
What you're trying to create is not technically a dropdown, but a collapse.
By definition, a dropdown is an element which has a toggle and a menu. When opened, the menu is displayed on top of the rest of the page. Typically, it's opaque and features a shadow. Opening the dropdown has no effect on the rest of the page (the layout does not change), the rest of the page does not re-render.
What you are trying to achieve here is a collapse. Collapse elements have a toggle and a body, very similar to dropdowns. But, unlike dropdowns, when opening, they push everything below, according to their body height. They are much heavier on browser rendering, because they trigger repaints on all layers (layout, paint and copositor), while animating, on all elements changing layout position while the collapse animates (typically on subsequent siblings - but potentially on the entire rest of the page).
The collapse body has a wrapper element which has maxHeight: 0
initially and then transitions to its scrollHeight
value when toggled. This creates a smooth transition for everything under it.
Here's a basic example, to demonstrate the principle:
const {
createApp,
defineComponent,
reactive,
watchEffect,
onMounted,
onBeforeUnmount,
toRefs
} = Vue;
const Collapse = defineComponent({
template: `
<div class="collapse-toggle" @click="toggle">
<slot name="toggle">{{ title }}</slot>
</div>
<div class="collapse-body" ref="bodyEl" :style="bodyStyle">
<slot></slot>
</div>
`,
props: {
title: {
type: String,
default: '--'
}
},
setup() {
const state = reactive({
isOpen: false,
bodyEl: null,
bodyStyle: {},
toggle: () => state.isOpen = !state.isOpen
});
const update = () => state.bodyStyle = {
maxHeight: `${state.isOpen ? state.bodyEl.scrollHeight : 0}px`
};
watchEffect(update);
onMounted(() => window.addEventListener('resize', update));
onBeforeUnmount(() => window.removeEventListener('resize', update));
return toRefs(state)
}
})
createApp({
components: { Collapse }
}).mount('#app')
.collapse-body {
overflow: hidden;
background-color: #f5f5f5;
max-height: 0;
padding: 0 1rem;
transition: max-height .3s cubic-bezier(.4,0,.2,1);
}
.collapse-toggle, .collapse-toggle * {
cursor: pointer;
}
<script src="https://unpkg.com/vue/dist/vue.global.prod.js"></script>
<div id="app">
<Collapse title="Collapse 1">
<p>Lorem ipsum dolor sit amet.</p>
<p>Lorem ipsum dolor sit amet.</p>
<p>Lorem ipsum dolor sit amet.</p>
<p>Lorem ipsum dolor sit amet.</p>
</Collapse>
<Collapse title="Collapse 2">
<p>Lorem ipsum dolor sit amet.</p>
<p>Lorem ipsum dolor sit amet.</p>
<p>Lorem ipsum dolor sit amet.</p>
<p>Lorem ipsum dolor sit amet.</p>
<p>Lorem ipsum dolor sit amet.</p>
<p>Lorem ipsum dolor sit amet.</p>
</Collapse>
<Collapse>
<template #toggle>
<button>With #toggle slot</button>
</template>
<p>Lorem ipsum dolor sit amet.</p>
<p>Lorem ipsum dolor sit amet.</p>
</Collapse>
</div>
Important note: Avoid setting top/bottom margins on the collapse wrapper. They'll create jumps in the animation. If you need such spacing, place them as top padding on the first content element, or bottom padding on the last one, respectively.