Table of Contents
- Insert Slot to Project Add Plan Button Text
- Project Add Plan Button Text
- Project Conditional Content
- Update CoffeePlan Component to Render Snippets
- Conclusions
- Resources
- Github Repositories
On day 17, I demonstrate how to render dynamic content in a component. Vue 3 projects content to slots
and it displays slot props optionally. In Svelte 5, slot is replaced with snippet
and the render
tag renders the snippet in the template. Angular offers ng-content
for content projection and ng-template
creates a template fragement that can display in a ng-container
.
In this blog post, there are two examples of content projection. The first example updates the text of the Add Plan
button when it is hovered. The second example renders a conditional slot in the CoffeePlan
component. When a coffee plan is selected, the plan displays the projected icons.
Insert Slot to Project Add Plan Button Text
- Vue 3 application
const hover = ref(false);
<button type="submit"
@mouseenter="hover = true"
@mouseleave="hover = false">
<slot name="btn" :hover="hover" />
</button>
In the AddCoffeePlan
component, a named slot is inserted as the child of the button element. hover
is a slot prop that returns the value of the hover
ref to the PlanPicker
component. The hover
ref is true when mouseenter
event occurs and false when mouseleave
event occurs.
The PlanPicker
component can use the slot prop value to project the button text.
- SvelteKit application
<script>
import type { Snippet } from 'svelte';
interface Props {
addPlanButton: Snippet<[boolean]>;
}
const { addPlanButton }: Props = $props();
let hover = $state(false);
</script>
<button type="submit"
onmouseenter={() => (hover = true)}
onmouseleave={() => (hover = false)}
>
{@render addPlanButton(hover)}
</button>
The CoffeePlan
component imports the Snippet
type to add typing to the addPlanButton
prop. addPlanButton
is a snippet that expects a boolean parameter. Moveover, hover
is a rune that sets to true on mouseenter
event and false on mouseleave
event.
The @render
tag passes the rune to the addPlanButton
snippet and renders it.
- Angular 19 application
@Component({
...
})
export class CoffeePlanComponent {
hover = output<boolean>();
}
<button type="submit"
(mouseenter)="hover.emit(true)"
(mouseleave)="hover.emit(false)"
>
<ng-content />
</button>
I could not find the equivalent concept of slot prop in Angular. Therefore, I defined a custom hover
event to emit the value to the PlanPicker
component.
I also inserted a <ng-content>
to the button element, so that the PlanPicker
component can project the text of the Add Plan button.
Project Add Plan Button Text
- Vue 3 application
<AddCoffeePlan>
<template #btn="{ hover }">
Add Plan {{ hover ? '(+1)' : '' }}
</template>
</AddCoffeePlan>
The body of the <AddCoffeePlan>
component has a template named #btn
. The slot prop is destructured to obtain the hover
property. When hover
is true, the button text is Add Plan (+1)
, otherwise, the text is Add Plan
.
- SvelteKit application
<AddCoffeePlan>
{#snippet addPlanButton(hover: boolean)}
Add Plan {hover ? '(+1)' : ''}
{/snippet}
</AddCoffeePlan>
The addPlanButton
snippet is declared inside the AddCoffeePlan
component; therefore, it is the implicit prop of the component. Similarly, the button text is Add Plan (+1)
when the hover
parameter is true. Otherwise, the text is Add Plan
.
- Angular 19 application
<app-add-coffee-plan (hover)="hover.set($event)">
{{ addPlanText() }}
</app-add-coffee-plan>
export class PlanPickerComponent {
hover = signal(false);
addPlanText = computed(() => `Add Plan ${this.hover() ? '(+1)' : ''}`);
}
The hover
custom event emits a boolean value that sets the hover
signal directly.
Then, the addPlanText
computed derives the button text based on the value of the hover
signal.
In the body of the <app-add-coffee-plan>
component, the addPlanText
getter is invoked and the text is projected to the Add Plan button.
Project Conditional Content
When a coffee plan is selected and the plan name starts with 'The', coffee and coffee maker icons are projected to it. When the selected plan does not start with 'The', tea and burger icons are projected. Unselected plan does not display any icon.
Install Icon libraries
Vue 3
npm install --save-dev @iconify/vue
- Svelte 5
npm install --save-dev @iconify/svelte
- Angular
npm install @ng-icons/core @ng-icons/material-icons
Create Templates to Render Different Icons
Vue 3 Application
import { Icon } from '@iconify/vue';
<CoffeePlan v-for="plan in plans">
<template #coffee v-if="isSelected(plan) && plan.startsWith('The')">
<div class="coffee">
<Icon class="icon" v-for="icon in ['ic:outline-coffee', 'ic:outline-coffee-maker']"
:key="icon" :icon="icon" />
</div>
</template>
<template #beverage v-if="isSelected(plan) && !plan.startsWith('The')">
<div class="beverage">
<Icon class="icon" v-for="icon in ['ic:outline-emoji-food-beverage', 'ic:outline-fastfood']" :key="icon" :icon="icon" />
</div>
</template>
</CoffeePlan>
Two named templates are created in the PlanPicker
component. The coffee
template is rendered when the selected plan starts with 'The'. The template displays the coffee and coffee maker icons. Plan that does not start with 'The', displays the beverage
template. The beverage
template renders the tea and burger icons.
The icon size is set to 48px in the coffee
template. The beverage
template has smaller blue icons, 42px.
- SvelteKit Application
import Icon from "@iconify/svelte";
{#snippet selectedPlanIcons()}
<div class="coffee">
{#each ['ic:outline-coffee', 'ic:outline-coffee-maker'] as name (name)}
<Icon icon={name} width="48" height="48" />
{/each}
</div>
{/snippet}
{#snippet selectedPlanBeverageIcons()}
<div class="beverage">
{#each ['ic:outline-emoji-food-beverage', 'ic:outline-fastfood'] as name (name)}
<Icon icon={name} width="42" height="42" color="blue" />
{/each}
</div>
{/snippet}
Similarly, the selectedPlanIcons
snippet renders the coffee and coffee maker icons and the selectedPlanBeverageIcons
snippet renders the tea and burger icons.
{#each plans as plan (plan)}
{#if isSelected(plan)}
<CoffeePlan
selectedPlanBeverageIcons={!plan.startsWith('The') ? selectedPlanBeverageIcons : undefined}
selectedPlanIcons={plan.startsWith('The') ? selectedPlanIcons : undefined} />
{:else}
<CoffeePlan name={plan} {selectedPlan} selected={isSelected(plan)} />
{/if}
{/each}
The snippets are passed to the CoffeePlan
component as props. When the selected plan does not start with 'The', the selectedPlanBeverageIcons
prop receives the selectedPlanBeverageIcons
template. Otherwise, the prop is undefined. When the name starts with 'The', the selectedPlanIcons
prop receives the selectedPlanIcons
template. Otherwise, the prop is undefined.
- Angular Application
Angular's NgTemplateOutlet is more suitable for this use case. The PlanPicker
component creates two template fragments, coffee and beverage, that render different icons. These fragments are passed to the CoffeePlan
component as signal inputs.
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
matCoffeeMakerOutline,
matCoffeeOutline,
matEmojiFoodBeverageOutline,
matFastfoodOutline,
} from '@ng-icons/material-icons/outline';
@Component({
selector: 'app-plan-picker',
imports: [NgIcon],
viewProviders: [
provideIcons({ matCoffeeMakerOutline, matCoffeeOutline, matEmojiFoodBeverageOutline, matFastfoodOutline }),
],
})
export class PlanPickerComponent {}
The PlanPicker
component imports the classes and SVG icons from the ng-icons
and ng-icons/material-icons
libraries.
<ng-template #coffee>
<div class="coffee">
@for (iconName of ['matCoffeeOutline', 'matCoffeeMakerOutline']; track iconName) {
<ng-icon class="icon" [name]="iconName" />
}
</div>
</ng-template>
.coffee {
flex: display;
align-items: center;
> .icon {
width: 48px;
height: 48px;
color: brown;
}
}
This template fragment has a template variable, coffee. It renders coffee and coffee maker icons and use simple CSS classes to adjust their dimensions and color.
<ng-template #beverage>
<div class="beverage">
@for (iconName of ['matEmojiFoodBeverageOutline', 'matFastfoodOutline']; track iconName) {
<ng-icon class="icon" [name]="iconName" />
}
</div>
</ng-template>
.beverage {
display: flex;
flex-direction: column;
padding: 0.25rem;
> .icon {
width: 42px;
height: 42px;
color: green;
}
}
The beverage
template fragment renders tea and burger icons and uses CSS classes to make them blue and 42px big.
@for (plan of plans(); track plan) {
@let coffeeTemplate = isSelected && plan.startsWith('The') ? coffee : undefined;
@let beverageTemplate = isSelected && !plan.startsWith('The') ? beverage : undefined;
<app-coffee-plan
[coffeeTemplate]="coffeeTemplate"
[beverageTemplate]="beverageTemplate"
/>
}
The coffeeTemplate
input receives the coffee
template fragment when the selected plan name begins with 'The'. The beverageTemplate
input receives the beverage
template fragment for selected plan that does not match the 'The' requirement.
Update CoffeePlan Component to Render Snippets
- Vue 3 Application
<div class="plan" @click="selectPlan" :class="{ 'active-plan': selected }">
<template v-if="$slots.coffee">
<slot name="coffee" />
</template>
<div class="description">
<span class="title"> {{ name }} </span>
</div>
<template v-if="$slots.beverage">
<slot name="beverage" />
</template>
</div>
When $slots.coffee
is true, the PlanPicker
component projects the coffee
template to the coffee
slot. The icons are displayed on the left side of the description.
When $slots.beverage
is true, the PlanPicker
component projects the beverage
template to the beverage
slot. The icons are displayed on the right side of the description.
- Svelte 5 Application
interface Props {
selectedPlanIcons?: Snippet;
selectedPlanBeverageIcons?: Snippet;
}
let {
name = 'Default Plan',
selectedPlan,
selected,
selectedPlanIcons,
selectedPlanBeverageIcons
}: Props = $props();
In the CoffeePlan
component, add optional selectedPlanIcons
and selectedPlanBeverageIcons
Snippet to the Props
interface.
Then, selectedPlanIcons
and selectedPlanBeverageIcons
are destructured from the component props.
<div>
{@render selectedPlanIcons?.()}
<div class="description">
<span class="title"> {name} </span>
</div>
{@render selectedPlanBeverageIcons?.()}
</div>
The render
tag renders the optional selectedPlanIcons
snippet on the left side of the description. The optional selectedPlanBeverageIcons
snippet is rendered on the right side of the description.
- Angular Application
@Component({
selector: 'app-coffee-plan',
imports: [NgTemplateOutlet],
})
export class CoffeePlanComponent {
coffeeTemplate = input<TemplateRef<any> | undefined>(undefined);
beverageTemplate = input<TemplateRef<any> | undefined>(undefined);
}
The CoffeePlan
component imports NgTemplateOutlet
to use the dirctive. coffeeTemplate
and beverageTemplate
are optional template references that refer to the fragments in the PlanPicker
component.
<div class="plan" (click)="selectPlan()" [class]="{ 'active-plan': selected() }">
<ng-container [ngTemplateOutlet]="coffee()" />
<div class="description">
<span class="title"> {{ name() }} </span>
</div>
<ng-container [ngTemplateOutlet]="beverage()" />
</div>
ngTemplateOutlet
is an input of the NgTemplateOutlet
directive. It accepts a template reference and displays the content of the ng-template
.
The first ng-container
embeds the coffee
template and the second ng-container
embeds the beverage
template. The coffee icons are rendered on the left side of the description and the beverage icons are rendered on the right side.
When ngTemplateOutlet
input is undefined, nothing is rendered.
Conclusions
We have successfully performed content projection and rendered conditional slots in the CoffeePlan
component. Vue 3 uses slots to display reusable templates. Svelte 5 introduces snippet and render to achieve the same thing. Angular provides ngContent for projection and ngTemplate to create template fragments that embed dynamic content in a ngContainer.
Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.