Skip to content
On this page

Animated Random Selector

This page Show an Example of an Animated Random Selector Component.

First Pattern

Demo

Selected winner:

Dependencies

{
    "tailwindcss": "latest", // for styling
}

Code

vue
<script setup lang="ts">
import { ref, computed, unref } from "vue";

const props = withDefaults(defineProps<{
  nameList: any[];
  havePreviousWinner?: boolean;
  maxReelItems?: number;
  shouldRemoveWinner?: boolean;
  refillLimit?: number;
  convertToString?: (data: any) => string;
  shouldAnimate?: boolean;
  buttonText?: string
}>(), {
  maxReelItems: 40,
  refillLimit: 1,
  shouldRemoveWinner: false,
  shouldAnimate: true,
  buttonText: 'Select'
})

const emit = defineEmits(['onSpinStart', 'onSpinEnd'])

const model = defineModel()

const localNameList = ref<any[]>(unref(props.nameList)) // to avoid 2 way mutation of prop passed by reference

const localHavePreviousWinner = ref(props.havePreviousWinner)

const reelContainer = ref<HTMLDivElement | null>(null)

const isAnimating = ref(false)

const reelAnimation = computed(() => reelContainer.value?.animate(
    [
      { transform: 'none', filter: 'blur(0)' },
      { filter: 'blur(1px)', offset: 0.5 },
      { transform: `translateY(-${(props.maxReelItems - 1) * (7 * 16)}px)`, filter: 'blur(0)' }
    ],
    {
      duration: props.maxReelItems * 100, // 100ms for 1 item
      easing: 'cubic-bezier(0.25, 1, 0.5, 1)',
      iterations: 1
    }

))


function shuffleNames<T = unknown>(array: T[]): T[] {
  const keys = Object.keys(array) as unknown[] as number[];
  const result: T[] = [];
  for (let k = 0, n = keys.length; k < array.length && n > 0; k += 1) {
    // eslint-disable-next-line no-bitwise
    const i = Math.random() * n | 0;
    const key = keys[i];
    result.push(array[key]);
    n -= 1;
    const tmp = keys[n];
    keys[n] = key;
    keys[i] = tmp;
  }
  return result;
}

async function  spin(): Promise<boolean> {
  isAnimating.value = true
  if (props.refillLimit && localNameList.value.length < props.refillLimit){
    //console.log("refilling now ---------------------", props.nameList)
    localNameList.value = props.nameList
  }

  if (!localNameList.value.length) {
    //console.error('Name List is empty. Cannot start spinning.');
    isAnimating.value = false
    return false;
  }

  if (!reelContainer.value || !reelAnimation.value) {
    //console.log('not fully ready')
    isAnimating.value = false
    return false;
  }

  // spin animation just started
  emit('onSpinStart')

  // Shuffle names and create reel items
  let randomNames = shuffleNames<string>(localNameList.value);

  while (randomNames.length && randomNames.length < props.maxReelItems) {
    randomNames = [...randomNames, ...randomNames];
  }

  randomNames = randomNames.slice(0, props.maxReelItems - Number(localHavePreviousWinner.value));

  const fragment = document.createDocumentFragment();

  randomNames.forEach((data) => {
    const newReelItem = document.createElement('div');
    newReelItem.innerHTML = props.convertToString ? props.convertToString(data): data;
    fragment.appendChild(newReelItem);
  });

  reelContainer.value.appendChild(fragment);

  // set the winner
  model.value = randomNames[randomNames.length - 1]

  // Remove winner form name list if necessary
  if (props.shouldRemoveWinner) {
    localNameList.value.splice(localNameList.value.findIndex(
        (name) => name === randomNames[randomNames.length - 1]
    ), 1);
  }

  if (props.shouldAnimate){
    // Play the spin animation
    const animationPromise = new Promise((resolve) => {
      reelAnimation.value!.onfinish = resolve;
    });

    reelAnimation.value.play();

    await animationPromise;

    // Sets the current playback time to the end of the animation
    // Fix issue for animation not playing after the initial play on Safari
    reelAnimation.value.finish();
  }

  // only render the winning div after animation
  Array.from(reelContainer.value.children)
      .slice(0, reelContainer.value.children.length - 1)
      .forEach((element) => element.remove());

  localHavePreviousWinner.value = true;

  // spin animation ended
  isAnimating.value = false
  emit('onSpinEnd')

  return true

}

</script>

<template>
  <div>
    <div v-show="model" id="lucky-draw">
      <div class="slot">
        <div class="slot__outer">
          <div class="slot__inner">
            <div class="slot__shadow"></div>
            <div ref="reelContainer" id="reel" class="reel"></div>
          </div>
        </div>
      </div>
    </div>

    <button class="my-4 rounded py-2 px-4 mx-auto bg-gray-500 text-white" :disabled="isAnimating" @click="spin">
        {{ buttonText }}
    </button>
  </div>
</template>


<style>
#lucky-draw {
	 width: 100%;
	 text-align: center;
	 position: relative;
     background-color: #6b7280;
     border-radius: 8px;
}

 .slot {
	 position: relative;
	 padding: 1rem;
}
 .slot__outer {
	 height: fit-content;
	 max-height: 7rem;
	 margin: 0 auto;
	 position: relative;
	 overflow: hidden;
}
 .slot__inner {
	 width: 100%;
	 height: 100%;
	 position: relative;
	 overflow: hidden;
}
 .reel {
	 width: 100%;
}
 .reel > div {
	 height: 7rem;
	 font-size: 2.5rem;
	 font-weight: bold;
	 text-align: center;
	 line-height: 7rem;
	 color: white;
	 text-overflow: ellipsis;
	 overflow: hidden;
	 white-space: nowrap;
	/* enable gpu accelaration to fix iOS flicker issue */
	 transform: translate3d(0, 0, 0);
}


</style>
vue
<script setup lang="ts">
import { ref } from "vue";
import RandomSelector from '../components/RandomSelector/RandomSelector.vue';

const selectedWinner = ref()

const names = ['Jimoh', 'Ayomide', 'Hikmah', 'Abdul', 'Jeff', 'Ade', 'Adekunle', 'Joe', 'Kromate', 'Aji']
</script>

<template>
    <div class="p-3 mt-4">
        <p class="my-4">Selected winner: {{ selectedWinner }}</p>

        <RandomSelector :nameList="names" v-model="selectedWinner" buttonText="Choose Winner" />
    </div>
</template>

Released under the MIT License.