Recipe - Photo Grid with Text & Button overlay • Content Component

Skill level: Intermediate

Organization: Chase Oaks Church

Requires Rock: 1.16.0


{# strip images & classes from the HTML but otherwise leave structure #}

I built this component to incorporate photos into our campus ministry navigation. The goal was to create a clean, visual navigation experience that helps people quickly scan a large number of ministries and easily find the one that's right for them—all in a warm, engaging way.

Features

  • Desktop Hover Effect: Photos transition from grayscale to full color on hover.
  • Mobile Scroll Effect: As you scroll, the photo closest to the center of the screen transitions from grayscale to full color, creating a subtle interactive effect.
  • Responsive Card Heights: Each card automatically adjusts its height based on its content. This allows individual cards to contain more information without breaking the overall grid layout. We use this for our non-English-speaking campuses, where some ministries don't yet have dedicated landing pages, allowing us to include ministry details and contact information directly within the card.

Required Content Component Item Attributes (There's nothing special about the names, it's just how our website was originally set up)

  • Image – The background image for the card.
  • LinkOne – The destination URL for the button.
  • LinkOneText – The button label.
  • LinkOneNewWindow – Field Type: Check List; Value: Yes. – Opens the link in a new window when selected.
  • It also uses Title and Content

HTML+Lava Template Code

This one has our church's classes; be sure to replace them with your own. (color-white btn)

<div class="container-fluid">
    <div class="row color-white text-center d-flex flex-wrap" style="font-weight:500;">

        {% for item in Items %}
            {% assign gridNumber = forloop.length %}
            {%- assign gridImage = item | Attribute:'Image','RawValue' -%}
            {%- assign gridTitle = item.Title -%}
            {%- assign gridContent = item.Content -%}
            {%- assign btnLink = item | Attribute:'LinkOne' -%}
            {%- assign btnText = item | Attribute:'LinkOneText' -%}
            {%- assign linkOneNewWindow = item | Attribute:'LinkOneNewWindow','Object' -%}
        
            <div class="{% if gridNumber == 8 %} col-md-3 image-card-height-eight {% elseif gridNumber == 6 %} col-md-4 image-card-height {% else %} col-md-6 image-card-height {% endif %} col-sm-6  col-xs-12 image-card-heigh image-card-hover d-flex" style="background-image:url('/GetImage.ashx?guid={{ gridImage }}');">
                <div class="card-body h-100 d-flex flex-column justify-content-center align-items-center">
                    <h3 class="color-white">{{ gridTitle }}</h3>
                    <p class="color-white">{{ gridContent }}</p>
                    {% if btnLink != "" and btnLink != "null" %}
                        <a class="btn bg-white" href="{{ btnLink }}" {% if linkOneNewWindow contains 'Yes' %}target="_blank" rel="nofollow"{% endif %}>{{ btnText }}</a>    
                    {% endif %}
                </div>
            </div>
        {% endfor %}
    </div>
</div>

CSS


<style>
.color-white {
    color:#fff;
}

/* sets the base height of image rows */
.image-card-height {
    min-height: 28vw;
}

/* reduces base height of image rows if there are 8 images */
.image-card-height-eight {
    min-height: 24vw;
}

.image-card-hover {
    background-size: cover;
    background-position: center top;
    position: relative;
    overflow: hidden;
    filter: grayscale(100%);
    transition: filter 0.3s ease;

}

/* image overlay – darkens photos */
.image-card-hover::before {
    content: "";
    position: absolute;
    inset: 0;
    background: rgba(0,0,0,0.6);
    z-index: 1;
    transition: background 0.3s ease;
}

.image-card-hover .card-body {
    position: relative;
    z-index: 2;
}


/* *** DESKTOP ONLY – adds greyscale to color hover effect *** */

@media (hover: hover) and (pointer: fine) {

    .image-card-hover:hover {
        filter: grayscale(0%);
    }
    
    /* image overlay – darkens photos – less */
    .image-card-hover:hover::before {
        background: rgba(0,0,0,0.4);
    }

}

/* *** MOBILE ONLY – absolutley stops all tap/hover/active/focus/before/after states on mobile *** */
/* *** sets MOBILE up for the greyscale to color scroll effect */

@media (max-width: 768px) {

/* adjust the base height of image rows */
    .image-card-height-eight {
    min-height: 30vh;
    }
/* adjust the base height of image rows if there are 8 images */    
    .image-card-height {
        min-height: 32vh;
    }

    .image-card-hover {
        /* FORCE default state */
        filter: grayscale(100%) !important;
    }

    .image-card-hover::before {
        background: rgba(0,0,0,0.6) !important;
    }

    /* Prevent tap/interaction states */
    .image-card-hover:hover,
    .image-card-hover:active,
    .image-card-hover:focus {
        filter: grayscale(100%) !important;
    }

    .image-card-hover:hover::before,
    .image-card-hover:active::before,
    .image-card-hover:focus::before {
        background: rgba(0,0,0,0.6) !important;
    }

    /* ONLY allow color via JS */
    .image-card-hover.active {
        filter: grayscale(0%) !important;
    }

    .image-card-hover.active::before {
        background: rgba(0,0,0,0.4) !important;
    }

    /* Optional: remove tap highlight flash */
    .image-card-hover {
        -webkit-tap-highlight-color: transparent;
    }

}

</style>

JavaScript


<!-- *** This script creates the scroll effect on mobile. Images loose greyscale in exact center of the screen and return to greyscale as the user scrols *** -->
<script>
document.addEventListener("DOMContentLoaded", function () {
    if (window.innerWidth > 768) return;

    const cards = document.querySelectorAll(".image-card-hover");

    function updateActiveCard() {
        const viewportCenter = window.innerHeight / 2;
        let closestCard = null;
        let closestDistance = Infinity;

        cards.forEach(card => {
            const rect = card.getBoundingClientRect();
            const cardCenter = rect.top + rect.height / 2;
            const distance = Math.abs(cardCenter - viewportCenter);

            if (distance < closestDistance) {
                closestDistance = distance;
                closestCard = card;
            }
        });

        cards.forEach(card => {
            if (card === closestCard) {
                card.classList.add("active");
            } else {
                card.classList.remove("active");
            }
        });
    }

    window.addEventListener("scroll", updateActiveCard);
    window.addEventListener("resize", updateActiveCard);
    updateActiveCard();
});
</script>

Screenshots

  • /GetImage.ashx?guid=25fbe01e-95b4-4080-a253-8c71f6d87f21
  • /GetImage.ashx?guid=153af63d-13a6-40b1-bd3b-54c001a65b2a
  • /GetImage.ashx?guid=e91af022-e23b-42e1-b479-ab2e40ef388c
  • /GetImage.ashx?guid=566de56c-a510-4e82-89e4-5511f32ad7b3