Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/hetari/creative-coding/llms.txt

Use this file to discover all available pages before exploring further.

A fixed-position footer sits behind a content section and is revealed as the user scrolls. GSAP ScrollTrigger drives three simultaneous animations: the two ASCII-rasterized hand images slide in from opposite sides of the viewport, the nav links and description paragraph lines slide upward, and the full name “Ebraheem Hetari” reveals character-by-character from opposite ends of each word. A useRafFn loop handles mouse parallax drift on the ASCII canvases every frame. The heading always spans the full viewport width at any breakpoint, achieved by measuring the text at a fixed 100 px reference size and scaling up to fill the available container width minus a responsive gap.

Registry Entry

This sketch is registered in src/config/sketches.ts as:
{
  id: 'gsap-footer-ascii-hand',
  title: 'Ascii Hand',
  category: 'gsap/footer',
  path: '/gsap/footer/ascii-hand',
  order: 20,
  previewMode: 'iframe',
}

Tech Stack

LayerTool
ASCII renderinguseAsciiRasterizer composable
GSAP / ScrollTriggeruseGsap composable (gsap, useGsapContext)
Render loopVueUse useRafFn
Responsive sizingVueUse useBreakpoints (Tailwind breakpoints)
Resize observationVueUse useResizeObserver
Event bindingVueUse useEventListener
Dark modeVueUse useDark
Smooth scrollLenisScroll component

Key Constants

All tuning values are declared at the top of the component as module-level constants:
const ASCII_CHARS = '........:::=+xX#0369'
const COLUMNS = 80
const CELL_SIZE = 20
const FONT_SIZE = 18
const PARALLAX_STRENGTH = 20
const PARALLAX_EASE = 0.08
const HOVER_RADIUS = 8
const HIGHLIGHT_LIFETIME = 300
const CLUSTER_SIZE = 10
ConstantPurpose
ASCII_CHARSCharacter ramp from lightest (.) to darkest (9) used when mapping luminance to glyphs
COLUMNSNumber of ASCII columns per canvas — determines horizontal resolution
CELL_SIZEPixel size of each cell in the rasterizer grid
FONT_SIZEFont size (px) of each ASCII character drawn to the canvas
PARALLAX_STRENGTHMaximum pixel offset for parallax drift (doubled for ± range)
PARALLAX_EASELerp factor applied each frame — lower values = more lag / smoother drift
HOVER_RADIUSRadius in cells that a mouse hover highlights around the cursor
HIGHLIGHT_LIFETIMEHow long (ms) a hover-highlighted cell stays lit after the cursor leaves
CLUSTER_SIZENumber of cells grouped into a single highlight cluster

Responsive Gap

VueUse useBreakpoints with Tailwind breakpoints computes the horizontal gap between the two heading words. The gap widens on larger screens so the name is always letter-spaced across the full viewport:
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMobile = breakpoints.smaller('sm')   // < 640px
const isTablet = breakpoints.between('sm', 'md')  // 640px – 768px
const isLaptop = breakpoints.between('md', 'lg')  // 768px – 1024px

const gapPx = computed(() => {
  if (isMobile.value) return 16
  if (isTablet.value) return 32
  if (isLaptop.value) return 64
  return 128
})

Dynamic Font Sizing

updateFittedFontSizes() measures both heading words at a fixed reference size of 100px using off-screen <span> elements, then scales up to fill the available container width minus gapPx:
function updateFittedFontSizes() {
  const container = headingContainerRef.value || rootRef.value
  if (!container) return
  const width = container.clientWidth || window.innerWidth
  if (!width) return

  const w0 = measureLine0Ref.value?.getBoundingClientRect().width || 1
  const w1 = measureLine1Ref.value?.getBoundingClientRect().width || 1
  const combinedWidthAt100 = w0 + w1

  const availableWidthForText = Math.max(0, width - gapPx.value)
  singleFontSize.value = (availableWidthForText / combinedWidthAt100) * 100
}
This function is re-run on resize, useResizeObserver on both headingContainerRef and rootRef, and whenever gapPx changes.

Scroll-Triggered Animations

All three animation groups share the same scrollTrigger config: trigger: triggerRef, start: 'top 80%', toggleActions: 'play reverse play reverse'. They are set up inside useGsapContext(rootRef, ...) so all tweens are scoped to the component.

1 — ASCII Curtain Reveal

The curtain.offset reactive value starts at 125 (percentage). GSAP tweens it to 0 on scroll. The useRafFn loop reads this offset every frame and applies it as translateX to each canvas wrapper, so the hands slide in from ±125% simultaneously:
gsap.to(curtain.value, {
  offset: 0,
  duration: 1,
  ease: 'power3.out',
  scrollTrigger: {
    trigger: triggerEl,
    start: 'top 80%',
    toggleActions: 'play reverse play reverse',
  },
})
All elements tagged [data-af-line] (nav link anchors and the paragraph) start with yPercent: 100 (clipped below their overflow-hidden parent). They slide up to yPercent: 0 with a stagger of 0.08s:
gsap.set(textLines, { yPercent: 100 })

gsap.to(textLines, {
  yPercent: 0,
  duration: 0.8,
  ease: 'power3.out',
  stagger: 0.08,
  scrollTrigger: { /* same trigger */ },
})

3 — Per-Character Heading Reveal

Each <h2> word is split into individual <span data-af-char> characters. Word 1 (“Ebraheem”) reveals from the last character inward (from: 'end'); Word 2 (“Hetari”) reveals from the first character outward (from: 'start'). Both run simultaneously at the same scroll trigger:
headingEls.forEach((h2, index) => {
  const wordChars = Array.from(h2.querySelectorAll<HTMLElement>('[data-af-char]'))
  const staggerFrom = index === 0 ? 'end' : 'start'

  gsap.set(wordChars, { yPercent: 125 })

  gsap.to(wordChars, {
    yPercent: 0,
    duration: 1.1,
    ease: 'power3.out',
    stagger: { each: 0.04, from: staggerFrom },
    scrollTrigger: { /* same trigger */ },
  })
})

Parallax & Render Loop

Mouse position is normalised within the footer bounds, then each canvas wrapper is transformed every frame inside useRafFn:
useRafFn(() => {
  const now = Date.now()
  leftAscii.render(now)
  rightAscii.render(now)

  drift.value.x += (pointer.value.x - drift.value.x) * PARALLAX_EASE
  drift.value.y += (pointer.value.y - drift.value.y) * PARALLAX_EASE

  wrappers.forEach((wrapper, i) => {
    if (!wrapper) return
    const revealX = i === 0 ? -curtain.value.offset : curtain.value.offset
    const x = drift.value.x * (i === 0 ? 1 : -1) || 0
    const y = -drift.value.y || 0
    wrapper.style.transform = `translateX(${revealX}%) translate(${x}px, ${y}px) scale(${scale})`
  })
})
The scale factor is derived from PARALLAX_STRENGTH to prevent the canvas edges from becoming visible when the parallax offsets are at their maximum extent.

Dark Mode

useDark() drives the character colours passed to both useAsciiRasterizer instances:
const isDark = useDark()

const computedCharColor = computed(() =>
  isDark.value ? '#803500' : '#e6b093'
)
const computedHoverColor = computed(() => '#ff6a00')
const computedHoverCharColor = computed(() =>
  isDark.value ? '#0f0f0f' : '#ffffff'
)
Because these are computed refs, changing the system colour scheme re-renders the ASCII canvases automatically on the next animation frame.
The ASCII canvases are rendered manually via useRafFn(() => render()) rather than using an autoLoop: true option inside useAsciiRasterizer. This gives the sketch precise control over render-loop timing: the parallax drift lerp runs in the same callback as the canvas draw, so motion and ASCII rendering are always in sync with no extra frame of lag.

Build docs developers (and LLMs) love