Skip to content

Instantly share code, notes, and snippets.

@loilo
Last active October 19, 2021 19:48
Show Gist options
  • Save loilo/d30d5f384fa62b1de8cdccc467cce139 to your computer and use it in GitHub Desktop.
Save loilo/d30d5f384fa62b1de8cdccc467cce139 to your computer and use it in GitHub Desktop.
A Container-Query-like Responsive Container in Vue.js

Vue Responsive Container

A container component reacting to breakpoints on its own width or height, powered by ResizeObserver and scoped slots.

It is similar to things like vue-resize in that you can use it to observe a component's size. However the ResponsiveContainer will not emit any events, its whole point is to work as declaratively as possible, taking a predefined set of breakpoints you can access in your component.

Basic Usage

The ResponsiveContainer component is used as a wrapper — an elementary example could look like this:

<ResponsiveContainer
  default-breakpoint="tiny"
  :breakpoints="{ small: 300, medium: 450, large: 600 }"
  v-slot="container"
  >
  The current breakpoint is: {{ container.breakpoint }}
</ResponsiveContainer>

The v-slot property above is the key to using the responsive container: It provides an API for reading information and detecting all kinds of changes.

Notes:

Caveats & Restrictions

  • The concept of the ResponsiveContainer is strictly one-dimensional, either width or height can be observed. You can however nest a width-observing and a height-observing container if needed.
  • The breakpoints are modeled after "classic" responsive design. Each breakpoint has a lower bound (or an upper bound if you set it to perform desktop-first) and there is no way to configure min-max-ranges et al.
  • The ResizeObserver used under the hood is not available in older browsers. There are polyfills that work well across browsers, but you'll have to include them yourself.
  • Also don't forget to install the lodash dependency.

API

The full API (in TypeScript syntax) looks like this:

// This is what gets passed to the `v-slot` attribute
interface ResponsiveContainerAPI {
  // Check properties of a specific [breakpoint]
  is: {
    // Is [breakpoint] the current one?
    exactly: BreakpointSwitches,

    // Is the current breakpoint smaller than or equal to [breakpoint]?
    atMost: BreakpointSwitches,

    // Is the current breakpoint smaller than [breakpoint]?
    smallerThan: BreakpointSwitches,

    // Is the current breakpoint larger than or equal to [breakpoint]?
    atLeast: BreakpointSwitches

    // Is the current breakpoint larger than [breakpoint]?
    largerThan: BreakpointSwitches
  },
  // The currently active breakpoint
  breakpoint: BreakpointName,

  // The default breakpoint
  defaultBreakpoint: BreakpointName,

  // The provided breakpoints name/size mapping
  breakpoints: { [key: BreakpointName]: BreakpointSize },

  // The used responsive strategy
  strategy: 'mobile-first' | 'desktop-first',

  // The breakpoints that are smaller than the current one
  smaller: BreakpointName[],

  // The breakpoints that are larger than the current one
  larger: BreakpointName[],

  // The current size (width or height) of the responsive container
  size: number
}

// Any of the given breakpoints' name
type BreakpointName = string

// Any of the given breakpoints' size
type BreakpointSize = number

// Maps all breakpoint names to true/false, depending on various conditions
type BreakpointSwitches = { [key: BreakpointName]: boolean }

Props

breakpoints

Type: { [name: string]: number }

The breakpoints to check for as a mapping from breakpoint names to sizes.


default-breakpoint

Type: string

The default breakpoint used when none of the given breakpoints matches.


strategy

Type: 'mobile-first' | 'desktop-first'

Default: 'mobile-first'

The strategy to use in the container. Basically decides if the default-breakpoint is smaller than all defined breakpoints (mobile-first) or larger (desktop-first).


dimension

Type: 'width' | 'height'

Default: 'width'

The dimension to observe.


throttle

Type: number

Default: 200

How many milliseconds are at least awaited between two size change reports. Throttling is leading and trailing.


tag

Type: string | object | null

Default: 'div'

The container needs to render an element that is watched by the ResizeObserver. By default, this is a <div>, but it can be any tag or Vue component. Rendering can be disabled through passing null, however this enforces passing a single child element to the slot, which will be used for measuring.

import throttle from 'lodash/throttle'
export default {
props: {
defaultBreakpoint: {
type: String,
default: 'default'
},
breakpoints: {
type: Object,
default: () => ({})
},
strategy: {
type: String,
default: 'mobile-first',
validator: value => value === 'mobile-first' || value === 'desktop-first'
},
dimension: {
type: String,
default: 'width',
validator: value => value === 'width' || value === 'height'
},
throttle: {
type: Number,
default: 200
},
tag: {
type: [String, Object, null],
default: 'div'
}
},
data: vm => ({
size: 0,
observedElement: null,
observer: vm.$isServer
? null
: new ResizeObserver(
throttle(entries => {
vm.size = entries[0].contentRect[vm.dimension]
}, vm.throttle)
)
}),
computed: {
isMobileFirst: vm => vm.strategy === 'mobile-first',
api() {
return {
is: {
exactly: this.exactly,
largerThan: this.gt,
smallerThan: this.lt,
atLeast: this.gte,
atMost: this.lte
},
breakpoint: this.currentBreakpoint,
defaultBreakpoint: this.defaultBreakpoint,
breakpoints: this.breakpoints,
strategy: this.strategy,
larger: this.largerBreakpoints,
smaller: this.smallerBreakpoints,
size: this.size
}
},
// The provided breakpoints object as iterable breakpoints array
// e.g. { a: 100, b: 200, c: 300 } => [{ name: 'a', size: 100 }, { name: 'b', size: 200 }, { name: 'c', size: 300 }]
iterableBreakpoints() {
return Object.entries(this.breakpoints).map(([name, size]) => ({
name,
size
}))
},
iterableBreakpointsWithDefault() {
if (this.isMobileFirst) {
return [
{
name: this.defaultBreakpoint
},
...this.iterableBreakpoints
]
} else {
return [
...this.iterableBreakpoints,
{
name: this.defaultBreakpoint
}
]
}
},
// The iterable breakpoints array, sorted by size
sizeSortedBreakpoints() {
return this.iterableBreakpoints
.slice(0)
.sort(({ size: aSize }, { size: bSize }) => aSize - bSize)
},
// The iterable breakpoints array, sorted by size depending on the strategy (ascending for desktop-first, descending for mobile-first)
strategicSizeSortedBreakpoints() {
return this.isMobileFirst
? this.sizeSortedBreakpoints.slice(0).reverse()
: this.sizeSortedBreakpoints.slice(0)
},
currentBreakpoint() {
for (const { name, size } of this.strategicSizeSortedBreakpoints) {
if (this.isMobileFirst) {
if (this.size >= size) return name
} else {
if (this.size <= size) return name
}
}
return this.defaultBreakpoint
},
// Names of breakpoints smaller than the current breakpoint
smallerBreakpoints() {
if (this.currentBreakpoint === this.defaultBreakpoint) {
if (this.isMobileFirst) {
return []
} else {
return this.sizeSortedBreakpoints.map(({ name }) => name)
}
}
const defaultBreakpointContainer = []
if (this.isMobileFirst) {
defaultBreakpointContainer.push(this.defaultBreakpoint)
}
return defaultBreakpointContainer.concat(
this.sizeSortedBreakpoints
.slice(
0,
this.sizeSortedBreakpoints.findIndex(
({ name }) => name === this.currentBreakpoint
)
)
.map(({ name }) => name)
)
},
// Names of breakpoints larger than the current breakpoint
largerBreakpoints() {
if (this.currentBreakpoint === this.defaultBreakpoint) {
if (this.isMobileFirst) {
return this.sizeSortedBreakpoints.map(({ name }) => name)
} else {
return []
}
}
const defaultBreakpointContainer = []
if (!this.isMobileFirst) {
defaultBreakpointContainer.push(this.defaultBreakpoint)
}
return this.sizeSortedBreakpoints
.slice(
this.sizeSortedBreakpoints.findIndex(
({ name }) => name === this.currentBreakpoint
) + 1
)
.map(({ name }) => name)
.concat(defaultBreakpointContainer)
},
exactly() {
return this.iterableBreakpointsWithDefault.reduce(
(carry, { name }) => ({
...carry,
[name]: name === this.currentBreakpoint
}),
{}
)
},
gt() {
return this.iterableBreakpointsWithDefault.reduce(
(carry, { name }) => ({
...carry,
[name]: this.smallerBreakpoints.includes(name)
}),
{}
)
},
gte() {
return {
...this.gt,
[this.currentBreakpoint]: true
}
},
lt() {
return this.iterableBreakpointsWithDefault.reduce(
(carry, { name }) => ({
...carry,
[name]: this.largerBreakpoints.includes(name)
}),
{}
)
},
lte() {
return {
...this.lt,
[this.currentBreakpoint]: true
}
}
},
render(h) {
const slot = this.$scopedSlots.default(this.api)
if (this.tag === null) {
return slot
} else {
return h(this.tag, [slot])
}
},
watch: {
dimension() {
this.observe(true)
}
},
mounted() {
this.observe()
},
updated() {
this.observe()
},
destroyed() {
this.observer.disconnect()
},
methods: {
observe(forceDisconnect = false) {
if (forceDisconnect || this.observedElement !== this.$el) {
if (forceDisconnect) {
// Disconnect observer completely
this.observer.disconnect()
} else if (this.observedElement) {
// Disconnect observed element
this.observer.unobserve(this.observedElement)
this.observedElement = null
}
// Connect observer
if (this.$el instanceof Element) {
this.observedElement = this.$el
this.observer.observe(this.$el)
}
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment