Skip to content

Instantly share code, notes, and snippets.

@MarioSo
Created June 12, 2019 12:37
Show Gist options
  • Select an option

  • Save MarioSo/59cb8f25dbe2cc0927e34911d77eeb1d to your computer and use it in GitHub Desktop.

Select an option

Save MarioSo/59cb8f25dbe2cc0927e34911d77eeb1d to your computer and use it in GitHub Desktop.
Wall of Text vue component by URSA MAJOR SUPERCLUSTER
<script>
import { TimelineMax, Power0, Power3 } from 'gsap'
import { find, hasClass } from '../scripts/elements'
import { addListener } from '../scripts/events'
import SpanOrBreak from './SpanOrBreak.vue'
/// UTILS
const shuffle = arr => {
const a = arr.slice(0)
let j, x, i
for (i = a.length - 1; i > 0; i--) {
j = Math.floor(Math.random() * (i + 1))
x = a[i]
a[i] = a[j]
a[j] = x
}
return a
}
const getRandomNumber = (from, to) => {
return Math.floor(Math.random() * to) + from
}
const pickRandom = arr => {
return arr[getRandomNumber(0, arr.length)]
}
const pickFillWords = (arr, arrSizes, length, num) => {
const words = []
while (words.length < num) {
const word = pickRandom(arr)
if (arrSizes[word.id].width > length) {
words.push(word)
}
}
return words
}
/// UTILS END
export default {
name: 'WallOfText',
components: {
SpanOrBreak,
},
data() {
const baseWords = [
'We',
'are',
'Ursa',
'Major',
'Supercluster',
'and',
'this',
'is',
'a',
'wall',
'of',
'text',
]
return {
// words,
sentences: [
['We', 'are', 'Ursa', 'Major', 'Supercluster'],
['and', 'this', 'is', 'a', 'wall', 'of', 'text'],
],
spacing: 20,
text: baseWords.map((t, i) => ({ id: i, word: t })),
words: [],
allWordsRefs: [],
fontsLoaded: false,
mobile: null,
// animation
revealAnimationIntro: null,
currentSentence: 0,
loop: true,
}
},
mounted() {
// wait for the font to be loaded in order to get the correct
// reference sizes of each word
this.onFontLoaded(() => {
this.$nextTick(function() {
this.words = this.setupWords()
this.mobile = this.isMobile()
this.$nextTick(function() {
this.revealAnimationIntro = this.createRevealAnimationIntro()
this.revealAnimationIntro.play(0)
this.$refs.textContainer.style.opacity = 1
})
})
})
addListener(window, 'resize', this.onResize)
},
methods: {
getMaxLines(width) {
if (width < 1024) {
return 22
}
if (width > 1367) {
return 14
}
return 18
},
setupWords() {
// talking about fullscreen word overlay here
const height =
Math.max(
document.documentElement.clientHeight,
window.innerHeight || 0
) -
2 * this.spacing
const width =
Math.max(document.documentElement.clientWidth, window.innerWidth || 0) -
2 * this.spacing
// get size of each word, tried a lot getBoundinClientRect came closest to the actual size
const sizes = this.$refs.refWords.map(e => {
const rect = e.getBoundingClientRect()
return {
width: rect.width,
height: rect.height,
}
})
return this.updateIdx(this.text, sizes, width, height)
},
updateIdx(text, textSizes, width, height) {
const returnText = [] // holding all text objects (spans & breaks)
const shuffleText = !this.mobile // do not shuffle on mobile b/c of limited screen space
const maxLines = this.getMaxLines(width) // differnet lines for different screens
const currentSize = {
// track the current w & h of the wall of text
width: 0,
height: 0,
}
let idx = 0
let i = 0
let lines = 0
let elementCount = 0
let randomWords = shuffleText ? shuffle(text) : text.slice(0)
this.allWordsRefs = [] // reset references
// insert words till the screen is packedk
while (
currentSize.width < width &&
currentSize.height < height &&
i < 400 // safety net to prevent infinite loop
) {
const el = randomWords.shift() // get a word
const space = width - currentSize.width // space left on the right
let lineClass = `js-line--${lines % 2 === 0 ? 'left' : 'right'}` // used for alternating animation
currentSize.width += textSizes[el.id].width
if (currentSize.width > width) {
const fillWords = pickFillWords(text, textSizes, space / 2, 2) // get a random fill word
// random word needs a fakeObject in order to be added to retrunText
const fakeObj = {
id: -1,
word: el.word,
key: 'ignore1-' + getRandomNumber(10000, 20000),
// text: fillWords[0].word,
text: `${fillWords[0].word} ${fillWords[0].word}`, // use the word twice in case it is to short
className: 'ignore-me ' + lineClass,
}
// create a second one for left/right side
const fakeObj2 = {
id: -2,
word: el.word,
key: 'ignore2-' + getRandomNumber(1, 10000),
text: `${fillWords[0].word} ${fillWords[0].word}`, // use the word twice in case it is to short
className: 'ignore-me ' + lineClass,
}
// line is over add a <br />
const breakObj = {
id: -99,
word: 'break',
key: 'break-' + getRandomNumber(1, 10000),
}
// add all the objects to the retured array
// first one needs to be at the beginning of the line
const indexToAdd = idx === 0 ? 0 : idx - elementCount + lines * 3
returnText.splice(indexToAdd, 0, fakeObj2)
returnText.push(fakeObj)
returnText.push(breakObj)
// some housekeeping
currentSize.width = textSizes[el.id].width
currentSize.height += textSizes[el.id].height
lines++
elementCount = 0 // will be increased in this loop
lineClass = `js-line--${lines % 2 === 0 ? 'left' : 'right'}`
}
if (Math.ceil(currentSize.height) >= height || lines >= maxLines) break
const id = idx // i can't be used since it will be increased on every loop
const skip = lines === 0 || lines === maxLines - 1 // do not animate first & last row
// this is the actual word which will be animated
const textObj = {
id,
word: el.word,
key: id,
ref: 'word-' + id,
className: `${el.word.toLowerCase().replace(' ', '-')} ${lineClass}`,
text: el.word,
skip,
}
// build up a reference array for each word
// needed to animate the sentence in order
if (!skip) {
if (this.allWordsRefs[el.word]) {
this.allWordsRefs[el.word] = [
...this.allWordsRefs[el.word],
textObj,
]
} else {
this.allWordsRefs[el.word] = [textObj]
}
}
returnText.push(textObj)
// if all words from the sentence are placed on the screen start over
if (randomWords.length === 0) {
randomWords = shuffleText ? shuffle(text) : text.slice(0)
}
// housekeeping
i++
idx++
elementCount++
}
return returnText
},
// ANIMATION
createRevealAnimationIntro() {
const allWords = find('.js-font-overlay__text')
const leftWords = find('.js-line--left')
const rightWords = find('.js-line--right')
const links = find('.js-link')
const quarter = Math.floor(allWords.length / 6)
const part1 = shuffle(allWords.slice(0, quarter))
const part2 = shuffle(allWords.slice(quarter, quarter * 2))
const part3 = shuffle(allWords.slice(quarter * 2, quarter * 3))
const part4 = shuffle(allWords.slice(quarter * 3, quarter * 4))
const part5 = shuffle(allWords.slice(quarter * 4, quarter * 5))
const part6 = shuffle(allWords.slice(quarter * 5))
// const stagger1 = 0.04
// const stagger2 = 0.04
const stagger3 = 0.04
const dur = 0.3
const dur1 = 1.5
const dur2 = 1.5
const dur3 = 0.5
const from = {
opacity: 0,
color: '#fbf7f3',
webkitTextStrokeColor: 'transparent',
ease: Power0.easeNone,
}
const from1 = { x: -100 }
const from2 = { x: 100 }
const to = { opacity: 1, ease: Power0.easeNone }
const to1 = { x: 0, ease: Power3.easeOut }
const to2 = { x: 0, ease: Power3.easeOut }
const to3 = {
opacity: 0.15,
color: 'transparent',
webkitTextStrokeColor: '#fbf7f3',
ease: Power0.easeNone,
}
return new TimelineMax({
paused: true,
onComplete: () => {
this.loop = true
this.introAnimation = false
this.createLoopAnimation()
},
})
.fromTo(allWords, dur, from, to, 0.3)
.fromTo(leftWords, dur1, from1, to1, 0.3)
.fromTo(rightWords, dur2, from2, to2, 0.3)
.staggerTo(part1, dur3, to3, stagger3, 2)
.staggerTo(part2, dur3, to3, stagger3, 2)
.staggerTo(part3, dur3, to3, stagger3, 2)
.staggerTo(part4, dur3, to3, stagger3, 2)
.staggerTo(part5, dur3, to3, stagger3, 2)
.staggerTo(part6, dur3, to3, stagger3, 2)
.staggerFromTo(links, 0.2, from, to, 0.05)
},
pickSentence() {
const sentence = this.sentences[this.currentSentence]
const maxTries = 10
let isValidSentence = false
let elements = []
let setToZero = false
let trys = 0
let wordTrys = 0
const fromRandom = this.mobile ? 0 : 2 // pick a random start index
const toRandom = this.mobile ? 2 : 3 // max word index
while (!isValidSentence && trys <= maxTries) {
let currentIdx = 0
let nextRandom = setToZero ? 0 : getRandomNumber(fromRandom, toRandom)
isValidSentence = true
trys++
elements = sentence.map(word => {
let w = this.allWordsRefs[word][nextRandom]
// if there are no more words in the array
if (!w) {
isValidSentence = false
setToZero = true
return null
}
// compare id to make sure the order is correct
while (w && w.id <= currentIdx && wordTrys <= maxTries) {
w = this.allWordsRefs[word][++nextRandom]
wordTrys++
}
if (!w) {
isValidSentence = false
setToZero = true
return null
}
currentIdx = w.id
// add dom element to elements array
return find('.js-font-overlay__text--' + w.id)[0]
})
}
// if still not valid
if (!isValidSentence) return []
this.currentSentence =
this.currentSentence + 1 === this.sentences.length
? 0
: this.currentSentence + 1
return elements
},
createLoopAnimation() {
if (!this.loop) return
if (this.loopAnimation) {
this.loopAnimation.progress(0).kill()
}
const dur = 0.2 // transition duration
const dur2 = 0.4 // transition duration
const delay = 2.4 // duration of text beeing fully white
const sentence = this.pickSentence()
if (sentence.length === 0) {
console.log('no sentence for you today')
return
}
this.loopAnimation = new TimelineMax({
delay: 1,
onComplete: () => {
if (!this.loop) return
this.createLoopAnimation()
},
})
.staggerTo(
sentence,
dur,
{
css: {
opacity: '1',
},
ease: Power0.easeNone,
},
0.1
)
.staggerTo(
sentence,
dur2,
{
css: {
color: '#fbf7f3',
webkitTextStrokeColor: 'transparent',
},
ease: Power0.easeNone,
},
0.1,
0.4
)
.staggerTo(
sentence,
dur,
{
css: {
color: 'transparent',
opacity: '0.15',
webkitTextStrokeColor: '#fbf7f3',
},
ease: Power0.easeNone,
},
0.1,
delay
)
},
// ANIMATION END
// haven't found a better solution to wait for fonts to be loaded
// using nuxt - if you have one I am happy to chat: @mario_sommer (on twitter)
onFontLoaded(cb) {
if (this.fontsLoaded) return
if (hasClass(document.documentElement, 'wf-active')) {
this.fontsLoaded = true
cb()
return
}
// try again in 100ms
setTimeout(() => {
this.onFontLoaded(cb)
}, 100)
},
isMobile() {
// TODO: put it in the store
return (
Math.max(document.documentElement.clientWidth, window.innerWidth || 0) <
1024
)
},
onResize() {
clearTimeout(this.resizeTimer)
this.resizeTimer = setTimeout(() => {
if (this.loopAnimation) {
this.loopAnimation.progress(0).kill()
}
this.words = this.setupWords()
this.loop = false
this.mobile = this.isMobile()
this.$nextTick(function() {
this.revealAnimationIntro = this.createRevealAnimationIntro()
// this.revealAnimationOut = this.createDisappearAnimation()
// this.revealAnimation = this.createRevealAnimation()
this.revealAnimationIntro.play(0)
})
}, 300)
},
},
}
</script>
<template>
<div class="wall-of-words">
<div ref="textContainer" class="overlay-words">
<SpanOrBreak
v-for="word in words"
ref="words"
:key="word.key"
:word="word"
/>
</div>
<div class="words-for-reference">
<span
v-for="refWord in text"
ref="refWords"
:key="refWord.id"
class="overlay-type-box"
><span class="js-font-overlay__text overlay-type"
>{{ refWord.word }}&nbsp;</span
>
</span>
</div>
</div>
</template>
<style lang="scss">
@import '../styles/tools.scss';
.wall-of-words {
position: absolute;
width: 100%;
height: 100%;
overflow: hidden;
}
// needed to calculate sizes
.words-for-reference {
position: absolute;
top: -9999px;
left: -9999px;
opacity: 0;
font-size: 0;
}
.overlay-words {
position: absolute;
letter-spacing: 0px;
top: 50%;
left: 50%;
height: calc((#{vh(100)} - (#{$base-spacing / 2} * 2)));
width: 400%;
box-sizing: border-box;
text-align: center;
margin-bottom: 0;
transform: translate(-50%, -50%);
font-size: 0;
line-height: 0;
@include media('>=tablet-l') {
letter-spacing: -1px;
height: calc((#{vh(100)} - (#{$base-spacing} * 2)));
}
}
.overlay-type-box {
display: inline-block;
overflow: hidden;
height: calc((#{vh(100)} - (#{$base-spacing / 2} * 2)) / var(--lines-mobile));
line-height: calc(
(#{vh(100)} - (#{$base-spacing / 2} * 2)) / var(--lines-mobile)
);
@include media('<tablet-l', 'landscape') {
height: calc(
(#{vh(100)} - (#{$base-spacing / 2} * 2)) / var(--lines-desktop)
);
line-height: calc(
(#{vh(100)} - (#{$base-spacing / 2} * 2)) / var(--lines-desktop)
);
}
@include media('>=tablet-l') {
height: calc((#{vh(100)} - (#{$base-spacing} * 2)) / var(--lines));
line-height: calc((#{vh(100)} - (#{$base-spacing} * 2)) / var(--lines));
}
@include media('>=tablet-xxl') {
height: calc((#{vh(100)} - (#{$base-spacing} * 2)) / var(--lines-desktop));
line-height: calc(
(#{vh(100)} - (#{$base-spacing} * 2)) / var(--lines-desktop)
);
}
&.has-break:after {
content: '\A';
white-space: pre;
}
}
.overlay-type {
// @include sec-font();
font-size: 100px;
font-family: 'Montserrat', sans-serif;
font-weight: 800;
display: inline-block;
font-size: calc(
(#{vh(100)} - (#{$base-spacing / 2} * 2)) / var(--lines-mobile)
);
-webkit-text-stroke-width: 1px;
-webkit-text-stroke-color: #fff;
color: transparent;
text-transform: uppercase;
opacity: var(--word-opacity, 0.12);
@include media('<tablet-l', 'landscape') {
font-size: calc(
(#{vh(100)} - (#{$base-spacing / 2} * 2)) / var(--lines-desktop)
);
}
@include media('>=tablet-l') {
font-size: calc((#{vh(100)} - (#{$base-spacing} * 2)) / var(--lines));
-webkit-text-stroke-width: 1.3px;
}
@include media('>=tablet-xxl') {
font-size: calc(
(#{vh(100)} - (#{$base-spacing} * 2)) / var(--lines-desktop)
);
-webkit-text-stroke-width: 1.3px;
}
}
</style>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment