How To Make an Image Banner Component with React

July 19, 2023

7 min read

How To Make an Image Banner Component with React
Watch on YouTube

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.

Image banner
Image banner

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.