Skip to content

Light Rays ​

A component with animated light rays which shine down from above, filling any container with a soft ambient glow.

Ambient glow

Light Rays

Drop this component into any container and it will fill the space with softly animated light rays shining from above.

Installation ​

Copy and paste the following code into your project:

vue
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { cn } from "@/lib/utils";

interface LightRaysProps {
  class?: string;
  count?: number;
  color?: string;
  blur?: number;
  speed?: number;
  length?: string;
  [key: string]: any;
}

interface LightRay {
  id: string;
  left: number;
  rotate: number;
  width: number;
  swing: number;
  delay: number;
  duration: number;
  intensity: number;
}

const props = withDefaults(defineProps<LightRaysProps>(), {
  count: 7,
  color: "rgba(160, 210, 255, 0.2)",
  blur: 36,
  speed: 14,
  length: "70vh",
});

const cycleDuration = computed(() => Math.max(props.speed, 0.1));

const createRays = (count: number, cycle: number): LightRay[] => {
  if (count <= 0) return [];

  return Array.from({ length: count }, (_, index) => {
    const left = 8 + Math.random() * 84;
    const rotate = -28 + Math.random() * 56;
    const width = 160 + Math.random() * 160;
    const swing = 0.8 + Math.random() * 1.8;
    const delay = Math.random() * cycle;
    const duration = cycle * (0.75 + Math.random() * 0.5);
    const intensity = 0.6 + Math.random() * 0.5;

    return {
      id: `${index}-${Math.round(left * 10)}`,
      left,
      rotate,
      width,
      swing,
      delay,
      duration,
      intensity,
    };
  });
};

const rays = ref<LightRay[]>([]);

onMounted(() => {
  rays.value = createRays(props.count, cycleDuration.value);
});

watch([() => props.count, cycleDuration], ([count, cycle]) => {
  rays.value = createRays(count, cycle);
});

const wrapperStyle = computed(() => ({
  "--light-rays-color": props.color,
  "--light-rays-blur": `${props.blur}px`,
  "--light-rays-length": props.length,
}));

const rayStyle = (ray: LightRay) => ({
  "--ray-left": `${ray.left}%`,
  "--ray-width": `${ray.width}px`,
  "--ray-rotate": `${ray.rotate}deg`,
  "--ray-swing": `${ray.swing}deg`,
  "--ray-intensity": `${ray.intensity}`,
  // Total animation time folds motion's repeatDelay (duration * 0.1) into the cycle.
  "--ray-duration": `${ray.duration * 1.1}s`,
  "--ray-delay": `${ray.delay}s`,
});
</script>

<template>
  <div
    :class="
      cn(
        'pointer-events-none absolute inset-0 isolate overflow-hidden rounded-[inherit]',
        props.class,
      )
    "
    :style="wrapperStyle"
    v-bind="$attrs"
  >
    <div class="absolute inset-0 overflow-hidden">
      <div
        aria-hidden="true"
        class="absolute inset-0 opacity-60"
        style="
          background: radial-gradient(
            circle at 20% 15%,
            color-mix(in srgb, var(--light-rays-color) 45%, transparent),
            transparent 70%
          );
        "
      />
      <div
        aria-hidden="true"
        class="absolute inset-0 opacity-60"
        style="
          background: radial-gradient(
            circle at 80% 10%,
            color-mix(in srgb, var(--light-rays-color) 35%, transparent),
            transparent 75%
          );
        "
      />
      <div v-for="ray in rays" :key="ray.id" class="light-ray" :style="rayStyle(ray)" />
    </div>
  </div>
</template>

<style scoped>
.light-ray {
  pointer-events: none;
  position: absolute;
  top: -12%;
  left: var(--ray-left);
  height: var(--light-rays-length);
  width: var(--ray-width);
  transform-origin: top;
  border-radius: 9999px;
  background: linear-gradient(
    to bottom,
    color-mix(in srgb, var(--light-rays-color) 70%, transparent),
    transparent
  );
  opacity: 0;
  mix-blend-mode: screen;
  filter: blur(var(--light-rays-blur));
  animation: light-ray-swing var(--ray-duration) ease-in-out var(--ray-delay) infinite;
}

@keyframes light-ray-swing {
  0% {
    opacity: 0;
    transform: translateX(-50%) rotate(calc(var(--ray-rotate) - var(--ray-swing)));
  }
  45.45% {
    opacity: var(--ray-intensity);
    transform: translateX(-50%) rotate(calc(var(--ray-rotate) + var(--ray-swing)));
  }
  90.9% {
    opacity: 0;
    transform: translateX(-50%) rotate(calc(var(--ray-rotate) - var(--ray-swing)));
  }
  100% {
    opacity: 0;
    transform: translateX(-50%) rotate(calc(var(--ray-rotate) - var(--ray-swing)));
  }
}
</style>

Usage ​

Place the component inside a relative, overflow-hidden container. It absolutely fills its parent, so give the parent an explicit height:

vue
<template>
  <div class="relative h-[400px] w-full overflow-hidden rounded-xl border">
    <LightRays />
  </div>
</template>

Props ​

PropTypeDefaultDescription
countnumber7Total number of animated rays.
colorstring"rgba(160, 210, 255, 0.2)"Base colour used for the gradients.
blurnumber36Pixel radius applied to the blur filter.
speednumber14Average seconds each ray takes to complete a cycle.
lengthstring"70vh"CSS length for the ray height.
classstringundefinedAdditional class names applied to the wrapper.

Released under the MIT License.