✨ Watch on YouTube | 🐙 GitHub | 🎮 Demo
Let's create a reusable banner component that looks nice, is dismissible, and has a call to action. Since we are developers and not graphic designers, we will use an image for the visual part. Here's an example of how I display it in my app at [increaser.org] to prompt people to watch a YouTube video related to the page's topic.
How to Use the ImageBanner Component
The ImageBannerComponent
accepts five properties:
-
onClose
- a function that will be called when the user clicks the close button -
action
- usually a button that will be displayed at the bottom of the banner -
title
- the title of the banner -
image
- an image for the background, represented as a React element instead of an image path to ensure compatibility with different frameworks like NextJS or Gatsby -
renderInteractiveArea
- a function that wraps the banner with an interactive element, such as an external link or a button that triggers an action in the app
interface ImageBannerProps {
onClose: () => void
action: ReactNode
title: ReactNode
image: ReactNode
renderInteractiveArea: (props: ComponentWithChildrenProps) => ReactNode
}
We can see how to use the banner by checking the HabitsEducationBanner
component in Increaser.
import { ExternalLink } from "router/Link/ExternalLink"
import { HABITS_EDUCATION_URL } from "shared/externalResources"
import { PersistentStorageKey } from "state/persistentStorage"
import { usePersistentStorageValue } from "state/usePersistentStorageValue"
import { ThemeProvider } from "styled-components"
import { Button } from "ui/Button/Button"
import { HSLA } from "ui/colors/HSLA"
import { YouTubeIcon } from "ui/icons/YouTubeIcon"
import { ImageBanner } from "ui/ImageBanner"
import { CoverImage } from "ui/images/CoverImage"
import { SafeImage } from "ui/images/SafeImage"
import { HStack } from "ui/Stack"
import { Text } from "ui/Text"
import { darkTheme } from "ui/theme/darkTheme"
const titleColor = new HSLA(220, 45, 30)
export const HabitsEducationBanner = () => {
const [interactionDate, setInteractionDate] = usePersistentStorageValue<
number | undefined
>(PersistentStorageKey.HabitsEducationWasAt, undefined)
const handleInteraction = () => {
setInteractionDate(Date.now())
}
if (interactionDate) return null
return (
<ThemeProvider theme={darkTheme}>
<ImageBanner
onClose={handleInteraction}
renderInteractiveArea={(props) => (
<ExternalLink
onClick={handleInteraction}
to={HABITS_EDUCATION_URL}
{...props}
/>
)}
action={
<Button size="xl" kind="reversed" as="div">
<HStack alignItems="center" gap={8}>
<YouTubeIcon />
<Text>Watch now</Text>
</HStack>
</Button>
}
title={
<Text as="span" style={{ color: titleColor.toCssValue() }}>
learn to build better habits
</Text>
}
image={
<SafeImage
src="/images/mountains.webp"
render={(props) => <CoverImage {...props} />}
/>
}
/>
</ThemeProvider>
)
}
We store the interaction timestamp in the local storage. If the user has already closed the banner or clicked on it, we don't show it again. For more information on setting up solid local storage, please refer to this article.
To avoid extra work, the banner will have the same appearance in both dark and light modes. We achieve this by wrapping it with a ThemeProvider
and making the dark theme the default for the banner.
We render the banner inside the ExternalLink
component, which is an anchor element. This will open the video in a new tab. To track interactions with the banner, we add an onClick
handler that updates the interaction date.
Since the banner is wrapped with an anchor, we don't want the action to be an interactive clickable element. Therefore, we render it as a div
element and exclude the onClick
handler. To ensure proper contrast between the title and the image background, we have selected a custom title color. For more information on effectively managing colors with HSLA format, please refer to this article.
Finally, we pass an image, which is wrapped with the SameImage
component. This component won't render anything if the image fails to load. I found the mountain image on Unsplash, but next time I might use AI generative tools like Midjourney.
import { ReactNode } from "react"
import { useBoolean } from "lib/shared/hooks/useBoolean"
interface RenderParams {
src: string
onError: () => void
}
interface Props {
src?: string
fallback?: ReactNode
render: (params: RenderParams) => void
}
export const SafeImage = ({ fallback = null, src, render }: Props) => {
const [isFailedToLoad, { set: failedToLoad }] = useBoolean(false)
return (
<>
{isFailedToLoad || !src
? fallback
: render({ onError: failedToLoad, src })}
</>
)
}
The CoverImage
component takes up the entire space and sets object-fit
to cover
:
import styled from "styled-components"
export const CoverImage = styled.img`
width: 100%;
height: 100%;
object-fit: cover;
`
How to Create the ImageBanner Component
Now, let's look at the implementation of the ImageBanner
component. Since we cannot render a button inside another button, we need to wrap the banner with a position-relative element and render the close button absolutely. We use an abstract component called ActionInsideInteractiveElement
to achieve this:
import { ReactNode } from "react"
import styled from "styled-components"
import { ElementSizeAware } from "./ElementSizeAware"
import { ElementSize } from "./hooks/useElementSize"
interface ActionInsideInteractiveElementRenderParams<
T extends React.CSSProperties
> {
actionSize: ElementSize
actionPlacerStyles: T
}
interface ActionInsideInteractiveElementProps<T extends React.CSSProperties> {
className?: string
render: (params: ActionInsideInteractiveElementRenderParams<T>) => ReactNode
action: ReactNode
actionPlacerStyles: T
}
const ActionPlacer = styled.div`
position: absolute;
`
const Container = styled.div`
position: relative;
`
export function ActionInsideInteractiveElement<T extends React.CSSProperties>({
render,
action,
actionPlacerStyles,
className,
}: ActionInsideInteractiveElementProps<T>) {
return (
<Container className={className}>
<ElementSizeAware
render={({ setElement, size }) => (
<>
{size &&
render({
actionPlacerStyles,
actionSize: size,
})}
<ActionPlacer ref={setElement} style={actionPlacerStyles}>
{action}
</ActionPlacer>
</>
)}
/>
</Container>
)
}
The close button has the background of a text element and contains a close icon. We position it 20px from the top and right of the banner.
import { ReactNode } from "react"
import styled from "styled-components"
import { ActionInsideInteractiveElement } from "./ActionInsideInteractiveElement"
import { defaultTransitionCSS } from "./animations/transitions"
import { CloseIcon } from "./icons/CloseIcon"
import { Panel } from "./Panel/Panel"
import { Text } from "./Text"
import { getColor } from "./theme/getters"
import { centerContentCSS } from "./utils/centerContentCSS"
import { fullyCoverAbsolutely } from "./utils/fullyCoverAbsolutely"
import { getSameDimensionsCSS } from "./utils/getSameDimensionsCSS"
import { interactiveCSS } from "./utils/interactiveCSS"
import { ComponentWithChildrenProps } from "lib/shared/props"
interface ImageBannerProps {
onClose: () => void
action: ReactNode
title: ReactNode
image: ReactNode
renderInteractiveArea: (props: ComponentWithChildrenProps) => ReactNode
}
const padding = "20px"
const ImagePosition = styled.div`
${fullyCoverAbsolutely}
${defaultTransitionCSS}
`
const PositionAction = styled.div`
position: absolute;
bottom: ${padding};
right: ${padding};
pointer-events: none;
${defaultTransitionCSS}
`
const Content = styled.div`
${fullyCoverAbsolutely}
padding: ${padding};
`
const Container = styled(Panel)`
position: relative;
min-height: 320px;
box-shadow: ${({ theme }) => theme.shadows.medium};
:hover ${PositionAction} {
transform: scale(1.06);
}
:hover ${ImagePosition} {
transform: scale(1.06);
}
:hover ${Content} {
background: ${getColor("mistExtra")};
}
`
const Title = styled(Text)`
text-transform: uppercase;
line-height: 1;
font-size: 40px;
@media (width <= 800px) {
font-size: 32px;
}
`
const Close = styled.button`
all: unset;
${interactiveCSS};
background: ${getColor("text")};
${defaultTransitionCSS};
color: ${getColor("background")};
border-radius: 8px;
${centerContentCSS};
${getSameDimensionsCSS(40)};
font-size: 20px;
:hover {
background: ${getColor("contrast")};
}
`
export const ImageBanner = ({
onClose,
action,
title,
image,
renderInteractiveArea,
}: ImageBannerProps) => {
const content = (
<Container>
<ImagePosition>{image}</ImagePosition>
<Content>
<Title weight="extraBold" as="h2">
{title}
</Title>
</Content>
<PositionAction>{action}</PositionAction>
</Container>
)
return (
<ActionInsideInteractiveElement
action={
<Close title="Dismiss" onClick={onClose}>
<CloseIcon />
</Close>
}
actionPlacerStyles={{
right: padding,
top: padding,
}}
render={() => renderInteractiveArea({ children: content })}
/>
)
}
We wrap the content with an interactive element based on the consumer's choice by calling renderInteractiveArea
and passing the banner itself as a child. The banner has the same shape as a Panel
component, with position
set to relative
, a defined min-height
, and a box shadow to make it stand out better in light mode. On hover, we make the image zoom in and make the button bigger by using transform: scale(1.06)
. To draw attention to the title and button, we blur the background of the content area with the mistExtra
color. For more information on an effective color palette for both dark and light modes, please check out my other article.
All children inside the Container
are positioned absolutely. We make the image take up the entire space and then render the content, which includes the title and blurs the image that is positioned below the container on hover. Finally, we position the action button and set pointer-event: none
, since the entire banner is already clickable.
Top comments (0)