25.1 C
New York
Wednesday, November 6, 2024

How you can Create an Natural Textual content Distortion Impact with Infinite Scrolling


Hello, everybody! It’s me once more, Jorge Toloza 👋 Final time I confirmed you the best way to code a progressive blur impact and in right this moment’s tutorial, we’ll create a dynamic textual content distortion impact that responds to scrolling, giving textual content an natural, wave-like movement.

Utilizing JavaScript and CSS, we’ll apply a sine wave to textual content within the left column and a cosine wave to the precise, creating distinctive actions that adapt to scroll pace and path. This interactive impact brings a clean, fluid really feel to typography, making it an attention-grabbing visible impact.

Let’s get began!

The HTML Markup

The construction could be very primary, consisting of solely a <div> containing our content material, which is separated by <p> tags.

<div class="column">
	<div class="column-content">
		<p>As soon as finishing the screenplay with Filippou and through the manufacturing course of...</p>
	</div>
</div>

CSS Kinds

Let’s add some kinds for our columns, together with width, paragraph margins, and drop-cap styling for the primary letter. In JavaScript, we’ll even be splitting our paragraphs into particular person phrases.

:root {
  --margin: 40rem;
  --gap: 20rem;
  --column: calc((var(--rvw) * 100 - var(--margin) * 2 - var(--gap) * 9) / 10);
}
.column {
  width: calc(var(--column) * 2 + var(--gap));
  peak: 100%;
  flex-shrink: 0;
  p {
    margin: 1em 0;
  }
  p.drop-cap {
    .line:first-child {
      .phrase:first-child {
        peak: 1em;
        &::first-letter {
          font-size: 6.85500em;
        }
      }
    }
  }
}

Splitting the Textual content

The plan is to maneuver every line independently to create an natural motion impact. To attain this, we have to separate every paragraph into strains. The logic is easy: we cut up the textual content into phrases and begin including them to the primary line. If the road turns into wider than the utmost width, we create a brand new line and add the phrase there, persevering with this course of for your complete paragraph.

That is the Utils file

const lineBreak = (textual content, max, $container) => {
  const getTotalWidth = ($el) =>
    Array.from($el.youngsters).scale back((acc, baby) => acc + baby.getBoundingClientRect().width, 0);
  
  const createNewLine = () => {
    const $line = doc.createElement('span');
    $line.classList.add('line');
    return $line;
  };
  // Step 1: Break textual content into phrases and wrap in span components
  const phrases = textual content.cut up(/s/).map((w, i) => {
    const span = doc.createElement('span');
    span.classList.add('phrase');
    span.innerHTML = (i > 0 ? ' ' : '') + w;
    return span;
  });

  // Step 2: Insert phrases into the container
  $container.innerHTML = '';
  phrases.forEach(phrase => $container.appendChild(phrase));

  // Step 3: Add left-space and right-space courses
  phrases.forEach((phrase, i) => {
    if (i > 0 && phrase.innerHTML.startsWith(' ')) {
      phrase.classList.add('left-space');
    }
    if (phrase.innerHTML.endsWith(' ')) {
      phrase.classList.add('right-space');
    }
  });

  // Step 4: Calculate whole width and create new strains if obligatory
  if (getTotalWidth($container) > max) {
    $container.innerHTML = '';
    let $currentLine = createNewLine();
    $container.appendChild($currentLine);

    phrases.forEach(phrase => {
      $currentLine.appendChild(phrase);
      if (getTotalWidth($currentLine) > max) {
        $currentLine.removeChild(phrase);
        $currentLine = createNewLine();
        $currentLine.appendChild(phrase);
        $container.appendChild($currentLine);
      }
    });
  } else {
    // If no line break is required, simply put all phrases in a single line
    const $line = createNewLine();
    phrases.forEach(phrase => $line.appendChild(phrase));
    $container.appendChild($line);
  }

  // Step 5: Wrap strains in `.textual content` span and take away empty strains
  Array.from($container.querySelectorAll('.line')).forEach(line => {
    if (line.innerText.trim()) {
      line.innerHTML = `<span class="textual content">${line.innerHTML}</span>`;
    } else {
      line.take away();
    }
  });
};

const getStyleNumber = (el, property) => {
  return Quantity(getComputedStyle(el)[property].substitute('px', ''));
}
const isTouch = () => {
  attempt {
    doc.createEvent('TouchEvent');
    return true;
  } catch (e) {
    return false;
  }
}

With our utility features prepared, let’s work on the SmartText part. It would parse our HTML to arrange it to be used in our lineBreak perform.

import Util from '../util/util.js';

export default class SmarText {
  constructor(choices) {
    this.$el = choices.$el;
    this.textual content = this.$el.innerText;
    this.init();
  }

  init() {
    // Parse phrases from the component's content material
    this.phrases = this.parseWords();
    this.$el.innerHTML = '';

    // Convert every phrase to a separate HTML component and append to container
    this.phrases.forEach((phrase) => {
      const component = this.createWordElement(phrase);
      this.$el.appendChild(component);
    });

    // Apply line breaks to realize responsive structure
    this.applyLineBreaks();
  }

  // Parse phrases from <p> and header components, distinguishing textual content and anchor hyperlinks
  parseWords() {
    const phrases = [];
    this.$el.querySelectorAll('p, h1, h2, h3, h4, h5, h6').forEach((p) => {
      p.childNodes.forEach((baby) => {
        if (baby.nodeType === 3) { // If textual content node
          const textual content = baby.textContent.trim();
          if (textual content !== '') {
            // Cut up textual content into phrases and wrap every in a SPAN component
            phrases.push(...textual content.cut up(' ').map((w) => ({ sort: 'SPAN', phrase: w })));
          }
        } else if (baby.tagName === 'A') { // If anchor hyperlink
          const textual content = baby.textContent.trim();
          if (textual content !== '') {
            // Protect hyperlink attributes (href, goal) for phrase component
            phrases.push({ sort: 'A', phrase: textual content, href: baby.href, goal: baby.goal });
          }
        } else {
          // For different component sorts, recursively parse baby nodes
          phrases.push(...this.parseChildWords(baby));
        }
      });
    });
    return phrases;
  }

  // Recursive parsing of kid components to deal with nested textual content
  parseChildWords(node) {
    const phrases = [];
    node.childNodes.forEach((baby) => {
      if (baby.nodeType === 3) { // If textual content node
        const textual content = baby.textContent.trim();
        if (textual content !== '') {
          // Cut up textual content into phrases and affiliate with the mum or dad tag sort
          phrases.push(...textual content.cut up(' ').map((w) => ({ sort: node.tagName, phrase: w })));
        }
      }
    });
    return phrases;
  }

  // Create an HTML component for every phrase, with courses and attributes as wanted
  createWordElement(phrase) {
    const component = doc.createElement(phrase.sort);
    component.innerText = phrase.phrase;
    component.classList.add('phrase');

    // For anchor hyperlinks, protect href and goal attributes
    if (phrase.sort === 'A') {
      component.href = phrase.href;
      component.goal = phrase.goal;
    }
    return component;
  }

  // Apply line breaks primarily based on the out there width, utilizing Util helper features
  applyLineBreaks() {
    const maxWidth = Util.getStyleNumber(this.$el, 'maxWidth');
    const parentWidth = this.$el.parentElement?.clientWidth ?? window.innerWidth;

    // Set the ultimate width, limiting it by maxWidth if outlined
    let finalWidth = 0;
    if (isNaN(maxWidth)) {
      finalWidth = parentWidth;
    } else {
      finalWidth = Math.min(maxWidth, parentWidth);
    }

    // Carry out line breaking inside the specified width
    Util.lineBreak(this.textual content, finalWidth, this.$el);
  }
}

We’re basically putting the phrases in an array, utilizing <a> tags for hyperlinks and <span> tags for the remaining. Then, we cut up the textual content into strains every time the consumer resizes the window.

The Column Class

Now for our Column class: we’ll transfer every paragraph with the wheel occasion to simulate scrolling. On resize, we cut up every paragraph into strains utilizing our SmartText class. We then guarantee there’s sufficient content material to create the phantasm of infinite scrolling; if not, we clone just a few paragraphs.

// Importing the SmartText part, possible for superior textual content manipulation.
import SmartText from './smart-text.js';

export default class Column {
  constructor(choices) {
    // Arrange fundamental component and configuration choices.
    this.$el = choices.el;
    this.reverse = choices.reverse;

    // Preliminary scroll parameters to manage clean scrolling.
    this.scroll = {
      ease: 0.05,     // Ease issue for clean scrolling impact.
      present: 0,     // Present scroll place.
      goal: 0,      // Desired scroll place.
      final: 0         // Final recorded scroll place.
    };

    // Monitoring contact states for touch-based scrolling.
    this.contact = {prev: 0, begin: 0};

    // Pace management, defaulting to 0.5.
    this.pace = {t: 1, c: 1};
    this.defaultSpeed = 0.5;

    this.goal = 0;  // Goal place for animations.
    this.peak = 0;  // Complete peak of content material.
    this.path = ''; // Monitor scrolling path.
    
    // Choose fundamental content material space and paragraphs inside it.
    this.$content material = this.$el.querySelector('.column-content');
    this.$paragraphs = Array.from(this.$content material.querySelectorAll('p'));

    // Bind occasion handlers to the present occasion.
    this.resize = this.resize.bind(this);
    this.render = this.render.bind(this);
    this.wheel = this.wheel.bind(this);
    this.touchend = this.touchend.bind(this);
    this.touchmove = this.touchmove.bind(this);
    this.touchstart = this.touchstart.bind(this);
    
    // Initialize listeners and render loop.
    this.init();
  }

  init() {
    // Connect occasion listeners for window resize and scrolling.
    window.addEventListener('resize', this.resize);
    window.addEventListener('wheel', this.wheel);
    doc.addEventListener('touchend', this.touchend);
    doc.addEventListener('touchmove', this.touchmove);
    doc.addEventListener('touchstart', this.touchstart);

    // Preliminary sizing and rendering.
    this.resize();
    this.render();
  }

  wheel(e) 

  touchstart(e) {
    // Document preliminary contact place.
    this.contact.prev = this.scroll.present;
    this.contact.begin = e.touches[0].clientY;
  }

  touchend(e) {
    // Reset goal after contact ends.
    this.goal = 0;
  }

  touchmove(e) {
    // Calculate scroll distance from contact motion.
    const x = e.touches ? e.touches[0].clientY : e.clientY;
    const distance = (this.contact.begin - x) * 2;
    this.scroll.goal = this.contact.prev + distance;
  }

  splitText() {
    // Cut up textual content into components for particular person animations.
    
    this.splits = [];
    const paragraphs = Array.from(this.$content material.querySelectorAll('p'));
    paragraphs.forEach((merchandise) => {
      merchandise.classList.add('smart-text');  // Add class for styling.
      if(Math.random() > 0.7)
        merchandise.classList.add('drop-cap'); // Randomly add drop-cap impact.
      this.splits.push(new SmartText({$el: merchandise}));
    });
  }

  updateChilds() {
    // Dynamically add content material copies if content material peak is smaller than window.
    const h = this.$content material.scrollHeight;
    const ratio = h / this.winH;
    if(ratio < 2) {
      const copies = Math.min(Math.ceil(this.winH / h), 100);
      for(let i = 0; i < copies; i++) {
        Array.from(this.$content material.youngsters).forEach((merchandise) => {
          const clone = merchandise.cloneNode(true);
          this.$content material.appendChild(clone);
        });
      }
    }
  }

  resize() {
    // Replace dimensions on resize and reinitialize content material.
    this.winW = window.innerWidth;
    this.winH = window.innerHeight;

    if(this.destroyed) return;
    this.$content material.innerHTML = '';
    this.$paragraphs.forEach((merchandise) => {
      const clone = merchandise.cloneNode(true);
      this.$content material.appendChild(clone);
    });
    this.splitText();     // Reapply textual content splitting.
    this.updateChilds();  // Guarantee enough content material for clean scroll.

    // Reset scroll values and put together for rendering.
    this.scroll.goal = 0;
    this.scroll.present = 0;
    this.pace.t = 0;
    this.pace.c = 0;
    this.paused = true;
    this.updateElements(0);
    this.$el.classList.add('no-transform');
    
    // Initialize gadgets with place and bounds.
    this.gadgets = Array.from(this.$content material.youngsters).map((merchandise, i) => {
      const information = { el: merchandise };
      information.width = information.el.clientWidth;
      information.peak = information.el.clientHeight;
      information.left = information.el.offsetLeft;
      information.prime = information.el.offsetTop;
      information.bounds = information.el.getBoundingClientRect();
      information.y = 0;
      information.additional = 0;

      // Calculate line-by-line animation particulars.
      information.strains = Array.from(information.el.querySelectorAll('.line')).map((line, j) => {
        return {
          el: line,
          peak: line.clientHeight,
          prime: line.offsetTop,
          bounds: line.getBoundingClientRect()
        }
      });
      return information;
    });

    this.peak = this.$content material.scrollHeight;
    this.updateElements(0);
    this.pace.t = this.defaultSpeed;
    this.$el.classList.take away('no-transform');
    this.paused = false;
  }

  destroy() {
    // Clear up sources when destroying the occasion.
    this.destroyed = true;
    this.$content material.innerHTML = '';
    this.$paragraphs.forEach((merchandise) => {
      merchandise.classList.take away('smart-text');
      merchandise.classList.take away('drop-cap');
    });
  }

  render(t) {
    // Important render loop utilizing requestAnimationFrame.
    if(this.destroyed) return;
    if(!this.paused) {
      if (this.begin === undefined) {
        this.begin = t;
      }

      const elapsed = t - this.begin;
      this.pace.c += (this.pace.t - this.pace.c) * 0.05;
      this.scroll.goal += this.pace.c;
      this.scroll.present += (this.scroll.goal - this.scroll.present) * this.scroll.ease;
      this.delta = this.scroll.goal - this.scroll.present;

      // Decide scroll path.
      if (this.scroll.present > this.scroll.final) {
        this.path = 'down';
        this.pace.t = this.defaultSpeed;
      } else if (this.scroll.present < this.scroll.final) {
        this.path = 'up';
        this.pace.t = -this.defaultSpeed;
      }
      
      // Replace component positions and proceed rendering.
      this.updateElements(this.scroll.present, elapsed);
      this.scroll.final = this.scroll.present;
    }
    window.requestAnimationFrame(this.render);
  }

  curve(y, t = 0) {
    // Curve impact to create non-linear animations.
    t = t * 0.0007;
    if(this.reverse) 
      return Math.cos(y * Math.PI + t) * (15 + 5 * this.delta / 100);
    return Math.sin(y * Math.PI + t) * (15 + 5 * this.delta / 100);
  }

  updateElements(scroll, t) {
    // Place and animate every merchandise primarily based on scroll place.
    if (this.gadgets && this.gadgets.size > 0) {
      const isReverse = this.reverse;
      this.gadgets.forEach((merchandise, j) => {
        // Monitor if gadgets are out of viewport.
        merchandise.isBefore = merchandise.y + merchandise.bounds.prime > this.winH;
        merchandise.isAfter = merchandise.y + merchandise.bounds.prime + merchandise.bounds.peak < 0;

        if(!isReverse) {
          if (this.path === 'up' && merchandise.isBefore) {
            merchandise.additional -= this.peak;
            merchandise.isBefore = false;
            merchandise.isAfter = false;
          }
          if (this.path === 'down' && merchandise.isAfter) {
            merchandise.additional += this.peak;
            merchandise.isBefore = false;
            merchandise.isAfter = false;
          }
          merchandise.y = -scroll + merchandise.additional;
        } else {
          if (this.path === 'down' && merchandise.isBefore) {
            merchandise.additional -= this.peak;
            merchandise.isBefore = false;
            merchandise.isAfter = false;
          }
          if (this.path === 'up' && merchandise.isAfter) {
            merchandise.additional += this.peak;
            merchandise.isBefore = false;
            merchandise.isAfter = false;
          }
          merchandise.y = scroll + merchandise.additional;
        }

        // Animate particular person strains inside every merchandise.
        merchandise.strains.forEach((line, okay) => {
          const posY = line.prime + merchandise.y;
          const progress = Math.min(Math.max(0, posY / this.winH), 1);
          const x = this.curve(progress, t);
          line.el.type.remodel = `translateX(${x}px)`;
        });
        
        merchandise.el.type.remodel = `translateY(${merchandise.y}px)`;
      });
    }
  }
}

Let’s take a better take a look at the resize methodology.

resize() {
  ...

  // Reset scroll values and put together for rendering.
  this.scroll.goal = 0;
  this.scroll.present = 0;
  this.pace.t = 0;
  this.pace.c = 0;
  this.paused = true;
  this.updateElements(0);
  this.$el.classList.add('no-transform');
  
  // Initialize gadgets with place and bounds.
  this.gadgets = Array.from(this.$content material.youngsters).map((merchandise, i) => {
    const information = { el: merchandise };
    information.width = information.el.clientWidth;
    information.peak = information.el.clientHeight;
    information.left = information.el.offsetLeft;
    information.prime = information.el.offsetTop;
    information.bounds = information.el.getBoundingClientRect();
    information.y = 0;
    information.additional = 0;

    // Calculate line-by-line animation particulars.
    information.strains = Array.from(information.el.querySelectorAll('.line')).map((line, j) => {
      return {
        el: line,
        peak: line.clientHeight,
        prime: line.offsetTop,
        bounds: line.getBoundingClientRect()
      }
    });
    return information;
  });

  this.peak = this.$content material.scrollHeight;
  this.updateElements(0);
  this.pace.t = this.defaultSpeed;
  this.$el.classList.take away('no-transform');
  this.paused = false;
}

We reset all scroll values and add a category to take away transformations so we are able to measure every part with out offsets. Subsequent, we arrange our paragraphs, saving their bounds and variables (y and additional) for motion.

Then, for every paragraph’s strains, we save their dimensions to allow horizontal motion.

Lastly, we get the total peak of the column and restart the animation.

The render methodology is easy: we replace all scroll variables and time. After that, we decide the present path to maneuver the weather accordingly. As soon as all variables are updated, we name the updateElements perform to begin the circulation.

render(t) {
  // Important render loop utilizing requestAnimationFrame.
  if(this.destroyed) return;
  if(!this.paused) {
    if (this.begin === undefined) {
      this.begin = t;
    }

    const elapsed = t - this.begin;
    this.pace.c += (this.pace.t - this.pace.c) * 0.05;
    this.scroll.goal += this.pace.c;
    this.scroll.present += (this.scroll.goal - this.scroll.present) * this.scroll.ease;
    this.delta = this.scroll.goal - this.scroll.present;

    // Decide scroll path.
    if (this.scroll.present > this.scroll.final) {
      this.path = 'down';
      this.pace.t = this.defaultSpeed;
    } else if (this.scroll.present < this.scroll.final) {
      this.path = 'up';
      this.pace.t = -this.defaultSpeed;
    }
    
    // Replace component positions and proceed rendering.
    this.updateElements(this.scroll.present, elapsed);
    this.scroll.final = this.scroll.present;
  }
  window.requestAnimationFrame(this.render);
}

With all that set, we are able to begin transferring issues.

updateElements(scroll, t) {
  // Place and animate every merchandise primarily based on scroll place.
  if (this.gadgets && this.gadgets.size > 0) {
    const isReverse = this.reverse;
    this.gadgets.forEach((merchandise, j) => {
      // Monitor if gadgets are out of viewport.
      merchandise.isBefore = merchandise.y + merchandise.bounds.prime > this.winH;
      merchandise.isAfter = merchandise.y + merchandise.bounds.prime + merchandise.bounds.peak < 0;

      if(!isReverse) {
        if (this.path === 'up' && merchandise.isBefore) {
          merchandise.additional -= this.peak;
          merchandise.isBefore = false;
          merchandise.isAfter = false;
        }
        if (this.path === 'down' && merchandise.isAfter) {
          merchandise.additional += this.peak;
          merchandise.isBefore = false;
          merchandise.isAfter = false;
        }
        merchandise.y = -scroll + merchandise.additional;
      } else {
        if (this.path === 'down' && merchandise.isBefore) {
          merchandise.additional -= this.peak;
          merchandise.isBefore = false;
          merchandise.isAfter = false;
        }
        if (this.path === 'up' && merchandise.isAfter) {
          merchandise.additional += this.peak;
          merchandise.isBefore = false;
          merchandise.isAfter = false;
        }
        merchandise.y = scroll + merchandise.additional;
      }

      // Animate particular person strains inside every merchandise.
      merchandise.strains.forEach((line, okay) => {
        const posY = line.prime + merchandise.y;
        const progress = Math.min(Math.max(0, posY / this.winH), 1);
        const x = this.curve(progress, t);
        line.el.type.remodel = `translateX(${x}px)`;
      });
      
      merchandise.el.type.remodel = `translateY(${merchandise.y}px)`;
    });
  }
}

This infinite scroll method is predicated on Luis Henrique Bizarro’s article. It’s fairly easy: we test if components are earlier than the viewport; in that case, we transfer them after the viewport. For components positioned after the viewport, we do the reverse.

Subsequent, we deal with the horizontal motion for every line. We get the road’s place by including its prime offset to the paragraph’s place, then normalize that worth by dividing it by the viewport peak. This offers us a ratio to calculate the X place utilizing our curve perform.

The curve perform makes use of Math.sin for regular columns and Math.cos for reversed columns. We apply a part shift to the trigonometric perform primarily based on the time from requestAnimationFrame, then enhance the animation’s power in response to the scroll velocity.

curve(y, t = 0) {
  // Curve impact to create non-linear animations.
  t = t * 0.0007;
  if(this.reverse) 
    return Math.cos(y * Math.PI + t) * (15 + 5 * this.delta / 100);
  return Math.sin(y * Math.PI + t) * (15 + 5 * this.delta / 100);
}

After that, it is best to have one thing like this:

And that’s a wrap! Thanks for studying.



Supply hyperlink

Related Articles

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Latest Articles