Pen Settings

HTML

CSS

CSS Base

Vendor Prefixing

Add External Stylesheets/Pens

Any URLs added here will be added as <link>s in order, and before the CSS in the editor. You can use the CSS from another Pen by using its URL and the proper URL extension.

+ add another resource

JavaScript

Babel includes JSX processing.

Add External Scripts/Pens

Any URL's added here will be added as <script>s in order, and run before the JavaScript in the editor. You can use the URL of any other Pen and it will include the JavaScript from that Pen.

+ add another resource

Packages

Add Packages

Search for and use JavaScript packages from npm here. By selecting a package, an import statement will be added to the top of the JavaScript editor for this package.

Behavior

Auto Save

If active, Pens will autosave every 30 seconds after being saved once.

Auto-Updating Preview

If enabled, the preview panel updates automatically as you code. If disabled, use the "Run" button to update.

Format on Save

If enabled, your code will be formatted when you actively save your Pen. Note: your code becomes un-folded during formatting.

Editor Settings

Code Indentation

Want to change your Syntax Highlighting theme, Fonts and more?

Visit your global Editor Settings.

HTML

              
                <nav>
    <menu>
        <li><a href="#" class="plucky">Root</a></li>
        <li><a href="#" class="plucky">Perfect Fifth</a></li>
        <li><a href="#" class="plucky">Perfect Fourth</a></li>
        <li><a href="#" class="plucky">Major Third</a></li>
    </menu>
</nav>

<p>A full explanation is on <a href="https://noahliebman.net/2022/12/waves-part-2-plucky/">my blog</a>.</p>

              
            
!

CSS

              
                body {
    display: flex;
    flex-direction: column;
    align-items: center;
    min-height: 100vh;
    margin: 0 12px;
    font-family: "Helvetica Neue", "Helvetica", "Arial", sans-serif;
    background-color: #1a0039;
    background-image: linear-gradient(to bottom, #1a0039, #120128);
    background-repeat: no-repeat;
    --body-color: #f9ebc4;
}

nav {
    margin-top: 100px;
}

nav menu {
    margin: 0;
    padding: 0;
    list-style: none;
    display: flex;
    justify-content: center;
    flex-wrap: wrap;
    column-gap: 2rem;
    row-gap: 1rem;
}

nav a {
    text-decoration: none;
    color: var(--body-color);
}

menu li {
    font-size: 2.3rem;
}

.plucky .plucky-container {
    display: flex;
    flex-direction: column;
}

.plucky .plucky-content {
    filter: drop-shadow(0 0 2px var(--body-color));
    z-index: 0;
}

.plucky svg {
    z-index: 1;
    display: block;
    overflow: visible; /* mostly just for the shadow */
    filter: drop-shadow(0 0 2px var(--body-color)) drop-shadow(0 0 2px var(--body-color)) drop-shadow(0 0 3px #00000080);
}

.plucky path {
    fill: none;
    stroke: var(--body-color);
    stroke-width: 2px;
}

p {
    font-size: 1.6rem;
    color: cornsilk;
}

              
            
!

JS

              
                import * as d3 from "https://cdn.skypack.dev/d3-shape@3.1.0";

class Plucky {
    constructor(navItem, config) {
        this.maxAmp = config?.maxAmp ?? 10; // maximum amplitude
        this.numHalfWaves = config?.numHalfWaves ?? 4; // number of half wavelengths
        this.pullDuration = config?.pullDuration ?? .12; // duration of "pull" (mouseover) tween
        this.releaseDuration = config?.releaseDuration ?? 3.5; // duration of "release" (mouseout) tween
        this.decayFreq = config?.decayFreq ?? 8; // controls how many cycles before fully damped
        this.touchDemo = config?.touchDemo ?? false; // when true, prevents default on touches for demo

        // wrap the original content
        this.container = document.createElement("div");
        this.container.classList.add("plucky-container");
        const content = document.createElement("div");
        content.classList.add("plucky-content");
        this.container.appendChild(content);
        content.innerHTML = navItem.innerHTML;
        navItem.innerHTML = "";
        navItem.appendChild(this.container);

        // set up the SVG
        this.svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
        this.coords = this.svg.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "g"));
        this.path = this.coords.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "path"));
        this.container.appendChild(this.svg);

        // calculate the dampening curve. ideally this would happen at build time, not in the client.
        this.dampCurve = this.calcDampCurve();
        if(!CustomEase.get("damped")) CustomEase.create("damped", this.dampCurve);

        this.container.addEventListener("mouseover", () => this.pull());
        this.container.addEventListener("mouseout", () => this.release());

        // you wouldn't actually want to preventDefault if links were to be clickable, but for this demo it’s ok
        if(this.touchDemo) {
            this.container.addEventListener("touchstart", e => { e.preventDefault(); this.pull() }, { passive: false });
            this.container.addEventListener("touchend", e => { e.preventDefault(); this.release() }, { passive: false });
            this.container.addEventListener("touchmove", e => e.preventDefault(), { passive: false });
        }
        
        window.addEventListener("load", () => {
            // measure some stuff. could do this onresize for more responsiveness.
            this.size();
            // set the initial amplitude to 0 (a flat line)
            this.amp = 0;
        });
    }

    pull() {
        gsap.killTweensOf(this);

        // gsap is going to tween the amplitude of this Plucky object
        gsap.to(this, { amp: this.maxAmp, ease: "power2.out", duration: this.pullDuration });
    }

    release() {
        gsap.to(this, { amp: 0, ease: "damped", duration: this.releaseDuration });
    }

    set amp(newAmp) {
        this._amp = newAmp;
        // whenever the amplitude changes (like during a gsap tween), update the path string
        this.path.setAttribute("d", this.generateWavePath());
    }
    get amp() {
        return this._amp;
    }

    // very heavily based on (aka copied) from source at
    // https://commons.wikimedia.org/wiki/File:Harmonic_partials_on_strings.svg
    // it implements a bezier approximation of a sine curve
    generateWavePath() {
        let amp = -this.amp;
        const XD = Math.PI / 12;
        const SQRT2 = Math.sqrt(2);
        const Y1 = (2 * SQRT2) / 7 - 1 / 7;
        const Y2 = (4 * SQRT2) / 7 - 2 / 7;
        const Y3 = SQRT2 / 2;
        const Y4 = (3 * SQRT2) / 7 + 2 / 7;

        const xmul = this.width / (this.numHalfWaves * Math.PI);
        const xd = XD * xmul;
        let x = 0;
        const y = 0;
        let path = `M ${x} 0`;

        for(let _ = 1; _ <= this.numHalfWaves; _++) {
            path = path + ` C ${x + xd}, ${y + amp * Y1}`
                        +  ` ${x + 2*xd}, ${y + amp * Y2}`
                        +  ` ${x + 3*xd}, ${y + amp * Y3}`
                        + ` C ${x + 4*xd}, ${y + amp * Y4}`
                        +  ` ${x + 5*xd}, ${y + amp}`
                        +  ` ${x + 6*xd}, ${y + amp}`
                        + ` C ${x + 7*xd}, ${y + amp}`
                        +  ` ${x + 8*xd}, ${y + amp * Y4}`
                        +  ` ${x + 9*xd}, ${y + amp * Y3}`
                        + ` C ${x + 10*xd}, ${y + amp * Y2}`
                        +  ` ${x + 11*xd}, ${y + amp * Y1}`
                        +  ` ${x + 12*xd}, ${y}`
            x+= this.width / this.numHalfWaves;
            amp = amp * -1; // flip over vertically every half wave
        }
        return path
    }

    // this calculates an exponentially damped easing curve.
    // $ y = -e^{-bt}\cos(2\pi ft) + 1 $
    calcDampCurve() {
        // b: dampening coefficient. higher numbers dampen more quickly.
        // fixing it at 5 has it reaching ~.01 by t = 1
        // adjusting the decayFreq and releaseDuration allows for full flexibility
        const b = 5;
        const decayFn = t => -1 * (Math.E ** (-b * t)) * Math.cos(2 * Math.PI * this.decayFreq * t) + 1;

        const samplesPerWavelength = 8;
        const sampleRate = samplesPerWavelength * this.decayFreq;

        let sampling = true;
        let stopNextZero = false;
        const samples = [];
        let T = 0;
        while(sampling) {
            const t = T/sampleRate;
            const y = decayFn(t);
            samples.push([t, -y]);

            if(T % samplesPerWavelength/2 === 0) { // near a local extreme
                if(Math.abs(y - 1) < .01) {
                    stopNextZero = true;
                }
            }
            if(stopNextZero && (T % samplesPerWavelength === 2 || T % samplesPerWavelength === 6)) { // at a zero crossing
                sampling = false;
            }
            else {
                T += 1;
            }
        }
        // use d3-shape to turn the points into a path string for gsap
        return d3.line().curve(d3.curveNatural)(samples);
    }

    size() {
        this.svg.setAttribute("width", 0);
        this.width = this.container.getClientRects()[0].width;
        const strokeWidth = window.parseFloat(window.getComputedStyle(this.path).getPropertyValue("stroke-width"));
        this.height = (this.maxAmp + strokeWidth/2) * 2;

        this.svg.setAttribute("width", this.width);
        this.svg.setAttribute("height", this.height);
        this.svg.setAttribute("viewBox", `0 0 ${this.width} ${this.height}`);
        this.svg.style.setProperty("margin-top", `${-(this.maxAmp - 1)}px`);

        this.coords.setAttribute("transform", `translate(0, ${this.height / 2})`);
    }
}

document.querySelectorAll(".plucky").forEach((ni, i) => new Plucky(ni, { numHalfWaves: i + 2, maxAmplitude: 9, touchDemo: true }));
              
            
!
999px

Console