Question:
How can I implement a custom horizontal auto-scrolling repeater gallery with specific dynamic styling and interactions in the Wix Editor (Classic), overcoming apparent Velo API limitations with .style
, .addClass
, .scrollTo
, .getBoundingClientRect
, .setAttribute
, and .onError
on relevant elements (Boxes, Containers, Images within repeaters)?
Product:
Wix Editor (Classic)
What are you trying to achieve:
I’m trying to create a highly customized portfolio gallery on a dynamic list page (“Services (Title)”). The gallery should display items from a Portfolio
collection, filtered based on the current service shown on the page (from the Services1
collection, using a matching tag in the typeOfService
field).
The core requirements are:
- Data Source:
Portfolio
collection with fields:thumbnail
(Image),title
(Text),shortDescription
(Text),typeOfService
(Tags). Filtered byServices1.title
matchingPortfolio.typeOfService
. - Layout (Desktop/Tablet -
formFactor === "Desktop"
):- Items arranged horizontally within a container.
- Container should allow horizontal scrolling (content wider than container).
- Repeater items (
#portfolioRepeaterContainer
) should have a fixed height of 800px. - Repeater items should have a max-width of 600px.
- Repeater items should have a right margin of 50px (except the last item).
- Layout (Mobile -
formFactor === "Mobile"
):- Items stacked vertically (standard repeater behavior).
- Repeater items (
#portfolioRepeaterContainer
) should have auto height. - Repeater items should have a bottom margin of 50px (except the last item).
- Styling (Repeater Item -
#portfolioRepeaterContainer
):- Background:
rgba(81, 63, 48, 0.8)
- Corners:
15px
- Padding:
20px
- Image (
#portfolioImage
):object-fit: contain
,border-radius: 15px
. - Title (
#portfolioTitle
):#FFFAF2
, EB Garamond Bold (50px Desktop, 30px Mobile). - Description (
#portfolioDescription
):#CCC8C2
, EB Garamond Normal (20px Desktop, 18px Mobile). - Button (
#portfolioButton
): Specific background/text colors, corners, shadow. Mobile:height: 26px
,width: auto
.
- Background:
- Functionality (Desktop):
- Critical: Auto-Scroll: The horizontal gallery container should automatically scroll horizontally using
setInterval
(e.g., every 5s). The scroll distance should be the width of one item + its right margin. - Critical: Pause on Hover: The auto-scroll must pause when the user hovers their mouse over the scrolling container. It should resume when the mouse leaves.
- Button Wiggle: A subtle, continuous wiggle animation on the
#portfolioButton
.
- Critical: Auto-Scroll: The horizontal gallery container should automatically scroll horizontally using
- Functionality (Mobile):
- No Auto-Scroll.
- Scroll-Triggered Animation: Each repeater item (
#portfolioRepeaterContainer
) should slide/fade in (e.g.,translateY
from 50px to 0,opacity
from 0 to 1) as it enters the viewport (onViewportEnter
). Should only animate once per item. - No Button Wiggle.
- Functionality (Common):
- Button Hover/Focus: Standard CSS hover/focus effect (color inversion, slight scale). (This part works fine with CSS).
- Dynamic Link: The
#portfolioButton
link should be dynamically generated based on the item’stitle
using a customgenerateSlug
function and base URL. - Image Error Handling: If an item’s
thumbnail
image fails to load, the image element (#portfolioImage
) should be hidden/collapsed, allowing the container’s background to show as a placeholder. - No Items Found: If the data query returns no items for the current service, the gallery container should be hidden, and a “No items found” text element (
#noPortfolioItemFoundText
) should be shown.
- Accessibility: Attempting to set
role="region"
and dynamicaria-label
on the main container, androle="listitem"
on repeater items.
Why Not Use a Standard Wix Pro Gallery?
While Pro Galleries offer some horizontal layouts and auto-scroll, they do not provide the level of customization required by this specification, specifically:
- The exact combination of fixed height (800px) with max-width (600px) for gallery items.
- Fine-grained control over the internal layout and styling of elements within each gallery item (specific fonts, sizes, padding, button styles).
- The specific auto-scroll behavior with a reliable pause-on-hover mechanism tied to the scroll container.
- Rounded corner settings with the scrolling layout.
I already tried setting this up using only a Wix Pro Gallery, but the following example was the closest I could get. (see link below) Additional customization is needed which is why I am trying to use code for this. 

See Example
What have you already tried:
- Structure: Implemented using a Strip (
#portfolioGalleryStrip
) containing a Box (#portfolioScrollBox
), which in turn contains the Repeater (#portfolioRepeater
). This was done because initial attempts using just a Strip or Box as the main container failed due to API limitations. - Velo Code: Used
$w.onReady
to fetch data viawix-data
(filtering using.hasSome
), populate the repeater (.data
), and set up form-factor specific logic. Used$w('#portfolioRepeater').onItemReady
to bind data, generate links, and apply item-specific logic/styles. - Custom CSS: Used Custom CSS (added via Dashboard > Custom Code > Head) for static styling, responsive layout (Flexbox for desktop horizontal layout on the repeater, overflow on the scroll box), and button hover effects.
- Specific API Attempts & Failures:
- Auto-Scroll: Attempted
setInterval
calling$w('#portfolioScrollBox').scrollTo(x, y)
. This fails withError: Expected 0 arguments, but got 2.
- Measuring Items: Attempted
await $item('#portfolioRepeaterContainer').getBoundingClientRect()
insideonItemReady
or on the template item to calculate scroll distance. This fails withProperty 'getBoundingClientRect' does not exist on type 'Container'
. Also tried on the Box (#portfolioScrollBox
) with similar results/errors. - Dynamic Styling: Attempted
$item('#portfolioRepeaterContainer').style.marginRight = '50px'
and similar forfontSize
,height
,width
on text/button elements insideonItemReady
. This fails withProperty 'style' does not exist on type 'Container'
or errors about specific properties likeheight
not existing on typeStyle
. - Mobile Animation (Class Trigger): Attempted
$item('#portfolioRepeaterContainer').addClass('in-view')
insideonViewportEnter
. This fails withProperty 'addClass' does not exist on type 'Container'
. - Image Error: Attempted
$item('#portfolioImage').onError(...)
. This fails withProperty 'onError' does not exist on type 'Image'
. - Accessibility Attributes: Attempted
$w('#portfolioScrollBox').setAttribute('role', 'region')
and$item('#portfolioRepeaterContainer').setAttribute('role', 'listitem')
. This fails withProperty 'setAttribute' does not exist on type 'Box'
or'Container'
.
- Auto-Scroll: Attempted
Code:
Velo Page Code (Services Title Page)
import wixData from 'wix-data';
import wixWindow from 'wix-window';
import { timeline } from 'wix-animations';
// --- Constants ---
const DESKTOP_CARD_MARGIN_RIGHT = 50;
const MOBILE_CARD_MARGIN_BOTTOM = 50;
const BASE_PORTFOLIO_URL = "https://www.fortheking.design/portfolio/";
// --- Global Variables ---
let desktopScrollIntervalId = null;
let currentServiceTitle = '';
let portfolioItems = [];
let isHovering = false;
// --- Helper Functions ---
function generateSlug(title) {
if (!title) return '';
const slug = title
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-#-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '');
return slug;
}
function setupButtonWiggleAnimation($item) {
const button = $item('#portfolioButton');
if (!button || !button.rendered) {
return;
}
const wiggleTimeline = timeline({ repeat: -1, yoyo: true, repeatDelay: 1500 })
.add(button, { rotate: -3, duration: 100, easing: 'easeInOutSine' })
.add(button, { rotate: 3, duration: 100, easing: 'easeInOutSine' })
.add(button, { rotate: 0, duration: 100, easing: 'easeInOutSine' });
wiggleTimeline.play();
}
function setupMobileSlideInTrigger($item) {
const container = $item('#portfolioRepeaterContainer');
let hasAnimated = false;
if (!container || !container.rendered) {
return;
}
container.onViewportEnter(() => {
if (!hasAnimated) {
// Error: Property 'addClass' does not exist on type 'Container'.
// container.addClass('in-view');
hasAnimated = true;
}
});
}
async function fetchPortfolioItems(serviceTitle) {
const scrollBox = $w('#portfolioScrollBox');
const notFoundText = $w('#noPortfolioItemFoundText');
if (!scrollBox || !notFoundText) {
console.error("fetchPortfolioItems: Critical elements #portfolioScrollBox or #noPortfolioItemFoundText not found.");
return;
}
try {
const results = await wixData.query('Portfolio')
.hasSome('typeOfService', [serviceTitle])
.fields('thumbnail', 'title', 'shortDescription', 'typeOfService', 'link-portfolio-title')
.limit(1000)
.find();
portfolioItems = results.items;
if (portfolioItems.length > 0) {
$w('#portfolioRepeater').data = portfolioItems;
scrollBox.expand();
$w('#portfolioGalleryStrip').expand();
notFoundText.collapse();
const formFactor = wixWindow.formFactor;
// Error: Argument of type 'string' is not assignable to parameter of type '"Desktop" | "Mobile"'. (Handled with check inside function)
setupLayoutAndAnimations(formFactor, scrollBox);
} else {
scrollBox.collapse();
$w('#portfolioGalleryStrip').expand();
notFoundText.expand();
}
} catch (error) {
console.error("fetchPortfolioItems: Error during query:", error);
scrollBox.collapse();
notFoundText.expand();
notFoundText.text = "Could not load portfolio items.";
}
}
function setupDesktopAutoScroll(scrollContainer) {
if (desktopScrollIntervalId) {
clearInterval(desktopScrollIntervalId);
desktopScrollIntervalId = null;
}
setTimeout(async () => {
try {
if (portfolioItems.length <= 1) return;
const firstItemContainer = $w('#portfolioRepeaterContainer');
if (!firstItemContainer || !firstItemContainer.rendered) return;
let itemWidth = 600; // Default fallback width
try {
// Error: Property 'getBoundingClientRect' does not exist on type 'Container'.
// const itemRect = await firstItemContainer.getBoundingClientRect();
// if (itemRect && itemRect.width > 0) itemWidth = itemRect.width;
} catch(rectError) { /* Use fallback */ }
const scrollDistance = itemWidth + DESKTOP_CARD_MARGIN_RIGHT;
const repeaterElement = $w('#portfolioRepeater');
let totalRepeaterWidth = portfolioItems.length * scrollDistance - DESKTOP_CARD_MARGIN_RIGHT;
try {
// const repeaterRect = await repeaterElement.getBoundingClientRect(); // Might also fail
// if (repeaterRect && repeaterRect.width > 0) totalRepeaterWidth = repeaterRect.width;
} catch (repeaterRectErr) { /* Use estimate */ }
// Error: Property 'getBoundingClientRect' does not exist on type 'Box'. (May depend on exact Box type/context)
// const containerRect = await scrollContainer.getBoundingClientRect();
const containerRect = { width: 1000 }; // Placeholder if getBoundingClientRect fails
if (!containerRect || containerRect.width <= 0) return;
if (totalRepeaterWidth <= containerRect.width) return;
let currentScrollX = 0;
const maxScrollX = totalRepeaterWidth - containerRect.width;
desktopScrollIntervalId = setInterval(() => {
if (!isHovering) {
currentScrollX += scrollDistance;
if (currentScrollX > maxScrollX + scrollDistance / 2) {
currentScrollX = 0;
}
// Error: Expected 0 arguments, but got 2.
// scrollContainer.scrollTo(currentScrollX, 0)
// .catch(e => console.error("Error during scrollTo:", e));
}
}, 5000);
scrollContainer.onMouseIn(() => { isHovering = true; });
scrollContainer.onMouseOut(() => { isHovering = false; });
} catch (err) {
console.error("Error setting up desktop auto-scroll:", err);
if (desktopScrollIntervalId) clearInterval(desktopScrollIntervalId);
}
}, 500);
}
function setupLayoutAndAnimations(formFactor, scrollContainer) {
if (formFactor !== 'Desktop' && formFactor !== 'Mobile') {
formFactor = 'Mobile'; // Fallback
}
if (formFactor === 'Desktop') {
setupDesktopAutoScroll(scrollContainer);
} else {
if (desktopScrollIntervalId) {
clearInterval(desktopScrollIntervalId);
desktopScrollIntervalId = null;
}
}
}
$w.onReady(function () {
$w('#portfolioScrollBox').collapse();
$w('#noPortfolioItemFoundText').collapse();
$w('#portfolioGalleryStrip').expand();
const scrollBox = $w('#portfolioScrollBox');
if (scrollBox) {
try {
// Error: Property 'setAttribute' does not exist on type 'Box'.
// scrollBox.setAttribute('role', 'region');
} catch (e) { /* Set in Editor */ }
}
const dynamicDataset = $w('#dynamicDataset');
if (!dynamicDataset) {
$w('#noPortfolioItemFoundText').text = "Page setup error: Dataset not found.";
$w('#noPortfolioItemFoundText').expand();
return;
}
dynamicDataset.onReady(async () => {
try {
const currentItem = dynamicDataset.getCurrentItem();
if (currentItem && currentItem.title) {
currentServiceTitle = currentItem.title;
if (scrollBox) {
try {
// Error: Property 'setAttribute' does not exist on type 'Box'.
// scrollBox.setAttribute('aria-label', `Portfolio Gallery for ${currentServiceTitle}`);
} catch (e) { /* Set in Editor */ }
}
await fetchPortfolioItems(currentServiceTitle);
} else {
$w('#noPortfolioItemFoundText').text = "Could not determine the current service.";
$w('#noPortfolioItemFoundText').expand();
$w('#portfolioScrollBox').collapse();
}
} catch (error) {
$w('#noPortfolioItemFoundText').text = "Error loading page data.";
$w('#noPortfolioItemFoundText').expand();
$w('#portfolioScrollBox').collapse();
}
});
});
$w('#portfolioRepeater').onItemReady(($item, itemData, index) => {
const formFactor = wixWindow.formFactor;
const imageElement = $item('#portfolioImage');
const titleElement = $item('#portfolioTitle');
const descriptionElement = $item('#portfolioDescription');
const buttonElement = $item('#portfolioButton');
const itemContainer = $item('#portfolioRepeaterContainer');
try {
// Error: Property 'setAttribute' does not exist on type 'Container'.
// itemContainer.setAttribute('role', 'listitem');
} catch(e) { /* Set in Editor */ }
if (itemData.thumbnail && imageElement) {
imageElement.src = itemData.thumbnail;
imageElement.alt = itemData.thumbnail.alt || itemData.title || "Portfolio image";
imageElement.expand();
} else if (imageElement) {
imageElement.collapse();
}
if (titleElement) titleElement.text = itemData.title || '';
if (descriptionElement) descriptionElement.text = itemData.shortDescription || '';
if (imageElement) {
// Error: Property 'onError' does not exist on type 'Image'.
// imageElement.onError(() => {
// imageElement.collapse();
// });
}
let fullUrl = '';
const slug = generateSlug(itemData.title);
if (slug) {
fullUrl = BASE_PORTFOLIO_URL + slug;
} else if (buttonElement) {
buttonElement.disable();
}
if (fullUrl && buttonElement) {
buttonElement.link = fullUrl;
buttonElement.target = "_blank";
buttonElement.enable();
} else if (buttonElement) {
buttonElement.disable();
}
if (!itemContainer) return;
if (formFactor === 'Desktop') {
try {
// Error: Property 'style' does not exist on type 'Container'.
// if (index < portfolioItems.length - 1) {
// itemContainer.style.marginRight = `${DESKTOP_CARD_MARGIN_RIGHT}px`;
// } else {
// itemContainer.style.marginRight = '0px';
// }
// itemContainer.style.marginBottom = '0px';
} catch (styleError) { /* Rely on CSS */ }
setupButtonWiggleAnimation($item);
} else { // Mobile
try {
// Error: Property 'style' does not exist on type 'Container'.
// if (index < portfolioItems.length - 1) {
// itemContainer.style.marginBottom = `${MOBILE_CARD_MARGIN_BOTTOM}px`;
// } else {
// itemContainer.style.marginBottom = '0px';
// }
// itemContainer.style.marginRight = '0px';
} catch (styleError) { /* Rely on CSS */ }
try {
// Error: Property 'style' does not exist or properties like 'fontSize'/'height' don't exist on type 'Style'.
// if (titleElement) titleElement.style.fontSize = '30px';
// if (descriptionElement) descriptionElement.style.fontSize = '18px';
// if (buttonElement) {
// buttonElement.style.height = '26px';
// buttonElement.style.width = 'auto';
// }
} catch (styleError) { /* Rely on CSS/Editor */ }
setupMobileSlideInTrigger($item);
}
});
Custom CSS
<style>
/* --- Portfolio Gallery Custom CSS --- */
#portfolioGalleryStrip { }
#portfolioScrollBox {
width: 100% !important;
box-sizing: border-box;
position: relative;
}
#portfolioRepeater {
display: flex !important;
width: max-content !important;
box-sizing: border-box;
padding: 0;
margin: 0;
visibility: visible !important;
opacity: 1 !important;
}
@media (min-width: 751px) {
#portfolioScrollBox {
overflow-x: hidden !important;
}
#portfolioRepeaterContainer {
height: 800px !important;
max-width: 600px;
width: 100%;
flex-shrink: 0;
margin-bottom: 0 !important;
/* Velo failed to set marginRight, apply via CSS */
margin-right: 50px;
}
/* Remove margin from last item */
#portfolioRepeater > div:last-child #portfolioRepeaterContainer {
margin-right: 0px !important;
}
#portfolioTitle { font-size: 50px; font-weight: bold; }
#portfolioDescription { font-size: 20px; font-weight: normal; }
}
@media (max-width: 750px) {
#portfolioScrollBox { overflow-x: hidden !important; }
#portfolioRepeater { display: block !important; width: 100% !important; }
#portfolioRepeaterContainer {
width: 100% !important;
height: auto !important;
max-width: none;
flex-shrink: 1;
margin-right: 0 !important;
/* Velo failed to set marginBottom, apply via CSS */
margin-bottom: 50px;
/* Mobile Animation Base State */
opacity: 0;
transform: translateY(50px);
transition: opacity 600ms ease-out, transform 600ms ease-out;
}
/* Remove margin from last item */
#portfolioRepeater > div:last-child #portfolioRepeaterContainer {
margin-bottom: 0px !important;
}
/* Mobile Animation Active State (if .addClass worked) */
#portfolioRepeaterContainer.in-view { opacity: 1; transform: translateY(0); }
/* Mobile styles (using !important as Velo .style failed) */
#portfolioTitle { font-size: 30px !important; font-weight: bold !important; }
#portfolioDescription { font-size: 18px !important; }
#portfolioButton { height: 26px !important; width: auto !important; padding: 5px 15px !important; }
}
#portfolioRepeaterContainer {
background-color: rgba(81, 63, 48, 0.8);
border-radius: 15px;
padding: 20px;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: space-between;
overflow: hidden;
}
#portfolioImage { width: 100%; object-fit: contain; border-radius: 15px; margin-bottom: 15px; }
#portfolioTitle { color: #FFFAF2; margin-bottom: 10px; word-wrap: break-word; }
#portfolioDescription { color: #CCC8C2; flex-grow: 1; margin-bottom: 20px; word-wrap: break-word; }
#portfolioButton {
background-color: #FFFAF2; color: #513F30; border-radius: 15px; cursor: pointer;
text-align: center; padding: 8px 20px; margin-top: auto; align-self: center;
box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.2);
transition: background-color 0.2s ease-out, color 0.2s ease-out, transform 0.2s ease-out, box-shadow 0.2s ease-out;
border: none; font-weight: bold; transform: scale(1.0);
}
#portfolioButton:hover,
#portfolioButton:focus {
background-color: #513F30; color: #FFFAF2; transform: scale(1.05);
box-shadow: 0px 6px 12px rgba(0, 0, 0, 0.3); outline: none;
}
#noPortfolioItemFoundText { text-align: center; padding: 40px 20px; color: #CCC8C2; width: 100%; }
*:focus-visible { outline: 2px solid #FFFAF2 !important; outline-offset: 2px; }
</style>
Additional information:
- Are these documented limitations of the Velo API for these specific element types in the Classic Editor?
- Are there alternative Velo APIs or techniques to achieve:
- Programmatic scrolling of a container (like a Box) with specific coordinates?
- Reliable measurement of element dimensions within repeaters?
- Adding/removing CSS classes to repeater items?
- Applying specific CSS styles (like margins) dynamically to repeater items?
- Handling image load errors via Velo?
- Setting ARIA attributes via Velo?
- Is there a different structural approach (e.g., using Custom Elements, different container types) within the Classic Editor that would make these APIs available or provide alternative solutions for the required functionality (especially the auto-scroll with hover pause)?