Tutorial: Product Gallery

We will build a product gallery page together during this tutorial using Vue 3 and Lynx. This tutorial does not assume any existing Lynx knowledge. The techniques you'll learn in the tutorial are fundamental to building any Lynx pages and applications.

Note

This tutorial is designed for people who prefer to learn by doing and want to quickly try making something tangible. If you prefer learning each concept step by step, start with Describing the UI.

What are we building?

A furniture gallery with a beautiful waterfall layout, tap-to-like interactions, auto-scrolling, and a custom scrollbar — driven by Main Thread Script for buttery-smooth performance. Each section builds incrementally on the previous one.

Setup for the tutorial

Check out the detailed quick start doc that will guide you through creating a new Lynx project. We recommend TypeScript for a better development experience, provided by static type checking and better editor IntelliSense.

You'll see lots of beautiful images throughout this guide. We've put together a package of sample images you can download here to use in your projects.

Your lynx.config.ts should look like this:

lynx.config.ts
import { defineConfig } from '@lynx-js/rspeedy';
import { pluginVueLynx } from 'vue-lynx/plugin';

export default defineConfig({
  source: {
    entry: {
      // We'll add entries here as we go
      ImageCard: './src/gallery/ImageCard/index.ts',
    },
  },
  plugins: [
    pluginVueLynx({
      optionsApi: false,
      enableCSSSelector: true,
    }),
  ],
});

Adding Styles

Since the focus of this tutorial is not on how to style your UI, you may just save some time and directly copy the below gallery.css file:

gallery.css
gallery.css
.gallery-wrapper {
  height: 100%;
  background-color: black;
}

.single-card {
  display: flex;
  align-items: center;
  justify-content: center;
}

.scrollbar {
  position: absolute;
  right: 7px;
  z-index: 1000;
  width: 4px;
  background: linear-gradient(to bottom, #ff6448, #ccddff, #3deae7);
  border-radius: 5px;
  overflow: hidden;
  box-shadow:
    0px 0px 4px 1px rgba(12, 205, 223, 0.4),
    0px 0px 16px 5px rgba(12, 205, 223, 0.5);
}

.scrollbar-effect {
  width: 100%;
  height: 80%;
}

.glow {
  background-color: #333;
  border-radius: 4px;
  background: linear-gradient(
    45deg,
    rgba(255, 255, 255, 0) 20%,
    rgba(255, 255, 255, 0.8) 50%,
    rgba(255, 255, 255, 0) 80%
  );
  animation: flow 3s linear infinite;
}

@keyframes flow {
  0% {
    transform: translateY(-100%);
  }
  100% {
    transform: translateY(100%);
  }
}

.list {
  width: 100%;
  padding-bottom: 20px;
  padding-left: 20px;
  padding-right: 20px;
  height: calc(100% - 48px);
  list-main-axis-gap: 10px;
  list-cross-axis-gap: 10px;
}

.picture-wrapper {
  border-radius: 10px;
  overflow: hidden;
  width: 100%;
}

.like-icon {
  position: absolute;
  display: grid;
  justify-items: center;
  align-items: center;
  top: 0px;
  right: 0px;
  width: 48px;
  height: 48px;
}

.heart-love {
  width: 16px;
  height: 16px;
}

.circle {
  position: absolute;
  top: calc(50% - 8px);
  left: calc(50% - 8px);
  height: 16px;
  width: 16px;
  border: 2px solid red;
  border-radius: 50%;
  transform: scale(0);
  opacity: 1;
  animation: ripple 1s 1 ease-out;
}

.circleAfter {
  animation-delay: 0.5s;
}

@keyframes ripple {
  0% {
    transform: scale(1);
    opacity: 1;
  }
  100% {
    transform: scale(2);
    opacity: 0;
  }
}

and import it in your entry file:

import '../gallery.css';

This makes sure your UI looks great when you are following this tutorial.

Styling variations in Lynx

Lynx supports a wide variety of styling features, including global styles, CSS Modules, inline styles, Sass, CSS variables, and more! Please refer to Rspeedy - Styling for how to pick your best styling configurations.

Your First Component: An Image Card

Now, let's start by creating the first image card, which will be the main part of this page.

Great, you can now see the image card displayed. Here, we use the <image> element to display your image. You only need to give it a width and height (or specify the aspectRatio property as shown here), and it will automatically resize to fit the specified dimensions. This component can receive a picture property via defineProps, allowing you to change the image it displays.

The src Attribute of Images

The Lynx <image> element can accept a local relative path as the src attribute to render an image. All images in this page are sourced locally, and these paths need to be imported before use. However, if your images are stored online, you can easily replace them with web image addresses.

Vue Lynx Entry Point

Like standard Vue, Vue Lynx uses createApp(Component).mount(). The difference is that there is no DOM element to mount to — mount() is called with no arguments and attaches to the Lynx page root automatically.

Adding interactivity: Like an Image Card

We can add a small white heart in the upper right corner and make it the like button for the image card. Here, we implement a small component called LikeIcon:

We want each card to know whether it has been liked, so we added isLiked, which is its internal data. It can use this internal data to save your changes.

LikeIcon.vue
<script setup lang="ts">
const isLiked = ref(false);
</script>

ref(false) creates a reactive variable. When isLiked.value changes, Vue's reactivity system automatically re-renders the component.

Then we add the @tap event to the wrapper <view>, so that when the user clicks the heart, it triggers this event and changes the state of isLiked:

LikeIcon.vue
<script setup lang="ts">
function onTap() {
  isLiked.value = true;
}
</script>

<template>
  <view class="like-icon" @tap="onTap">
    ...
  </view>
</template>
What is "@tap"?

Vue Lynx uses the same @eventName syntax you know from Vue on the web, but Lynx has its own set of events — @tap instead of @click, @scroll instead of native scroll, etc. Learn more on the Event Handling page.

Finally, we use isLiked to control the like effect. Because isLiked is reactive, LikeIcon will respond to its changes, turning into a red heart icon. The <view> elements used to render the ripple animation are conditionally rendered with v-if:

LikeIcon.vue
<template>
  <view class="like-icon" @tap="onTap">
    <view v-if="isLiked" class="circle" />
    <view v-if="isLiked" class="circle circleAfter" />
    <image :src="isLiked ? redHeart : whiteHeart" class="heart-love" />
  </view>
</template>

We then compose LikeIcon with ImageCard into a LikeImageCard component (see the code in the viewer above).

To give the like a better visual interaction effect, we added CSS animations in gallery.css. You can also learn more about animations in the Animation section and replace them with your preferred style!

Displaying More Images with <list>

To show all your beautiful images, you may need help from <list>. This way, you will get a scrollable page that displays a large number of similar images:

Special child elements of list

Each child of <list> needs to be <list-item>, and you must specify a unique and non-repeating :key and :item-key attribute, otherwise it may not render correctly.

We use a waterfall layout as the child node layout option. <list> also accepts other layout types, which you can refer to in the <list> documentation.

Info

You can refer to the Scrolling documentation to learn more about scrolling and scrolling elements.

Auto-Scrolling via Element Methods

If you want to create a desktop photo wall, you need to add an auto-scroll feature to this page. Your images will be slowly and automatically scrolled, allowing you to easily see more images:

GalleryAutoScroll/Gallery.vue
<script setup lang="ts">
import { onMounted, nextTick } from 'vue-lynx';

onMounted(() => {
  nextTick(() => {
    lynx
      .createSelectorQuery()
      .select('[custom-list-name="list-container"]')
      .invoke({
        method: 'autoScroll',
        params: { rate: '60', start: 'true' },
      })
      .exec();
  });
});
</script>

We use onMounted combined with nextTick to call the autoScroll method after the list is rendered. We use lynx.createSelectorQuery() to select the list element by its custom-list-name attribute and invoke the native method.

What is "invoke"?

In Lynx, all native elements have a set of "methods" that can be called. Unlike on the web, this call is asynchronous, similar to message passing. You need to use invoke with the method name and parameters to call them.

onMounted + nextTick

onMounted runs once when the component is mounted. We call nextTick inside it to wait for the main thread to finish creating the native elements. In Vue Lynx, nextTick is enhanced to wait for the cross-thread ops flush — not just Vue's internal scheduler — so elements are guaranteed to exist when the callback fires.

How about a Custom Scrollbar?

Like most apps, we can add a scrollbar to this page to indicate how many images are left to be displayed. But we can do more! For example, we can replace the default progress bar of <list> with our preferred style:

The NiceScrollbar component exposes an adjustScrollbar method via defineExpose, which lets the parent component call methods on the child. Similar to the @tap event used to add the like functionality, we add the @scroll event to <list>, which will be triggered when the <list> element scrolls. To make the scrollbar respond faster to scroll events, we set scroll-event-throttle to 0.

list-item's estimated-main-axis-size-px

You may have noticed this attribute on <list-item>. It estimates the size of elements on the main axis when they are not yet rendered in <list>. This is very useful when we add a scrollbar, as we need to know how long the scrollbar needs to be to cover all elements.

We provide a utility method to estimate the size based on the current layout and image dimensions:

utils.ts
export const calculateEstimatedSize = (
  pictureWidth: number,
  pictureHeight: number,
): number => {
  const galleryPadding = 20;
  const galleryMainAxisGap = 10;
  const gallerySpanCount = 2;
  const galleryWidth = SystemInfo.pixelWidth / SystemInfo.pixelRatio;
  const itemWidth = (galleryWidth - galleryPadding * 2 - galleryMainAxisGap)
    / gallerySpanCount;
  return (itemWidth / pictureWidth) * pictureHeight;
};

At this point, we have a complete page! But you may have noticed that the scrollbar we added still lags a bit during scrolling, not as responsive as it could be. This is because our adjustments are still happening on the background thread, not the main thread that responds to touch scrolling.

What are the background thread and main thread?

The biggest feature of Lynx is its dual-thread architecture. You can find a more detailed introduction in JavaScript Runtime.

A More Responsive Scrollbar

To optimize the performance of the scrollbar, we need to introduce Main Thread Script (MTS) to handle events on the main thread, migrating the adjustments we made in the previous step for the scrollbar's height and position from the background thread to the main thread.

To let you see the comparison more clearly, we keep both scrollbars:

The MTS scrollbar uses :main-thread-ref to bind a MainThreadRef to the element, allowing the Main Thread to directly manipulate it without any background thread round-trips.

Now you should be able to see that the scrollbar on the left (darkkhaki), controlled with main thread scripting, is smoother and more responsive compared to the scrollbar on the right that we implemented earlier. If you encounter issues in other UIs where updates need to happen immediately, try this method.

Key Vue concepts for MTS:

  • useMainThreadRef(null) — Creates a ref that can be resolved on the Main Thread
  • :main-thread-bindscroll="ctx" — Binds a worklet handler to the scroll event (runs on Main Thread, zero thread crossings)
  • .toJSON() — Serializes the ref for cross-thread transfer (includes the internal _wvid identifier)

Wrapping Up

We remove the redundant scrollbar used for comparison, and our Gallery is now complete! Let's take a look at the final result:

Congratulations! You have successfully created a product gallery page! Throughout this tutorial, you've covered the basics of writing interactive UIs on the Lynx platform with Vue 3.

React vs Vue Quick Reference

ConceptReact LynxVue Lynx
Create approot.render(<App />)createApp(App).mount()
Stateconst [x, setX] = useState(val)const x = ref(val)
Update statesetX(newVal)x.value = newVal
Template refconst ref = useRef(null)const el = ref(null)
LifecycleuseEffect(() => ..., [])onMounted(() => ...)
Class nameclassName="foo"class="foo"
Conditional{cond && <el />}<el v-if="cond" />
List{arr.map(x => <el key={x} />)}<el v-for="x in arr" :key="x" />
Tap eventbindtap={handler}@tap="handler"
Scroll eventbindscroll={handler}@scroll="handler"
MTS scrollmain-thread:bindscroll={fn}:main-thread-bindscroll="ctx"
MTS refmain-thread:ref={ref}:main-thread-ref="ref"
MT ref hookuseMainThreadRef(null)useMainThreadRef(null)
Expose to parentforwardRef + useImperativeHandledefineExpose({ method })
Propsfunction Comp(props: { x: T })defineProps<{ x: T }>()

Running the Examples

# Install dependencies
pnpm install

# Build Vue runtime packages
cd packages/vue/runtime && pnpm build
cd packages/vue/main-thread && pnpm build

# Start the dev server
cd packages/vue/e2e-lynx && pnpm dev

Then open each bundle in LynxExplorer:

EntryBundleWhat it shows
ImageCardImageCard.lynx.bundleSingle image card
LikeCardLikeCard.lynx.bundleCard with tap-to-like
GalleryListGalleryList.lynx.bundleWaterfall grid
GalleryAutoScrollGalleryAutoScroll.lynx.bundleAuto-scrolling gallery
GalleryScrollbarGalleryScrollbar.lynx.bundleGallery + BG Thread scrollbar
GalleryScrollbarCompareGalleryScrollbarCompare.lynx.bundleBG vs MT scrollbar comparison
GalleryCompleteGalleryComplete.lynx.bundleFinal gallery with MTS scrollbar