ConfettiDocs
For developers

When to show the survey

Control when and to whom the survey appears — including hide-after-response and dismiss cool-downs — or read a respondent's history to drive your own UI.

For finer control over when the survey shows, render the widget yourself and drive its visibility from your application code.

By default, the survey keeps appearing — including for people who already responded or dismissed it. Confetti still records each respondent's lastRespondedAt and lastDismissedAt. Pass an isSurveyVisible predicate (examples below) to hide or re-show the survey from that history.

Prefer the widget's isSurveyVisible prop over calling the respondent REST API yourself. The package already fetches those timestamps for you.

Scenario 1: Don't show again after someone has responded

When to use this

Use this when each person should answer the survey at most once — for example a one-off CSAT or onboarding pulse.

Without this predicate, yes, the survey will show again on later visits, even after a response.

How to configure it

import { PopoverConfetti } from '@opengovsg/confetti'

const YourComponent = () => {
  const { id: userId } = useUser()

  return (
    <div style={{ position: 'fixed', bottom: '1rem', right: '1rem' }}>
      <PopoverConfetti
        surveyId="<your-survey-id>"
        publishableKey="<your-publishable-key>"
        respondent={userId}
        // Hide forever once this respondent has submitted.
        isSurveyVisible={({ lastRespondedAt }) => !lastRespondedAt}
      />
    </div>
  )
}

To ask again later instead (for example every 30 days):

const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000

isSurveyVisible={({ lastRespondedAt }) =>
  !lastRespondedAt || Date.now() - lastRespondedAt.getTime() >= THIRTY_DAYS_MS
}

Pass a stable respondent (usually your signed-in user id) so history is tracked per person across devices. If you omit it, Confetti uses an anonymous per-browser id.

Scenario 2: Don't show again for X days after a dismiss

When to use this

Use this when closing the survey should quiet it for a cool-down — for example hide for 3 days after dismiss, then offer it again.

lastDismissedAt is set only when the respondent abandons the survey before submitting (close button or modal dismiss). Completing the survey does not set lastDismissedAt, so also check lastRespondedAt if you want completers hidden too.

How to configure it

import { PopoverConfetti } from '@opengovsg/confetti'

const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000

const YourComponent = () => {
  const { id: userId } = useUser()

  return (
    <div style={{ position: 'fixed', bottom: '1rem', right: '1rem' }}>
      <PopoverConfetti
        surveyId="<your-survey-id>"
        publishableKey="<your-publishable-key>"
        respondent={userId}
        isSurveyVisible={({ lastRespondedAt, lastDismissedAt }) => {
          // Already answered — keep hidden (or swap in a response cool-down).
          if (lastRespondedAt) return false
          // Never dismissed — show.
          if (!lastDismissedAt) return true
          // Dismissed — show again only after the cool-down.
          return Date.now() - lastDismissedAt.getTime() >= THREE_DAYS_MS
        }}
      />
    </div>
  )
}

Scenario 3: Combine response and dismiss cool-downs

When to use this

Use this for recurring CSAT: wait longer after a completed response than after a dismiss. Example: re-ask 30 days after a response, or 3 days after a dismiss.

How to configure it

import { PopoverConfetti } from '@opengovsg/confetti'

const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000

const YourComponent = () => {
  const { id: userId } = useUser()

  return (
    <div style={{ position: 'fixed', bottom: '1rem', right: '1rem' }}>
      <PopoverConfetti
        surveyId="<your-survey-id>"
        publishableKey="<your-publishable-key>"
        respondent={userId}
        isSurveyVisible={({ lastRespondedAt, lastDismissedAt }) => {
          const now = Date.now()
          if (
            lastRespondedAt &&
            now - lastRespondedAt.getTime() < THIRTY_DAYS_MS
          ) {
            return false
          }
          if (
            lastDismissedAt &&
            now - lastDismissedAt.getTime() < THREE_DAYS_MS
          ) {
            return false
          }
          return true
        }}
      />
    </div>
  )
}

Scenario 4: Display the survey after a specific user action

When to use this

Use this when you want feedback immediately after a meaningful interaction. For example:

  • The user clicks "Generate a summary"
  • The user downloads a dataset
  • The user submits a form

How to configure it

import '@opengovsg/confetti/confetti.css'

import { PopoverConfetti } from '@opengovsg/confetti'

const YourComponent = () => {
  const [showConfetti, setShowConfetti] = useState(false)

  const clickHandler = useCallback(() => {
    doSomeBusinessLogic()
    setShowConfetti(true)
  }, [doSomeBusinessLogic, setShowConfetti])

  return (
    <>
      <div style={{ position: 'fixed', bottom: '1rem', right: '1rem' }}>
        <PopoverConfetti
          surveyId="<your-survey-id>"
          publishableKey="<your-publishable-key>"
          isSurveyVisible={showConfetti}
        />
      </div>
      <Button onClick={clickHandler}>Do action</Button>
    </>
  )
}

Combine this with Scenario 1 or Scenario 2 when the action trigger should still respect response or dismiss history — for example isSurveyVisible={({ lastRespondedAt }) => showConfetti && !lastRespondedAt}.

Scenario 5: Display the survey after X minutes on a page

When to use this

Use this when you want feedback from engaged users — for example, only after the user has spent one minute on the page.

How to configure it

import '@opengovsg/confetti/confetti.css'

import { PopoverConfetti, useVisibleAfterDelay } from '@opengovsg/confetti'

const YourComponent = () => {
  const { isVisible } = useVisibleAfterDelay({ delay: 60 * 1000 })

  return (
    <div style={{ position: 'fixed', bottom: '1rem', right: '1rem' }}>
      <PopoverConfetti
        surveyId="<your-survey-id>"
        publishableKey="<your-publishable-key>"
        isSurveyVisible={isVisible}
      />
    </div>
  )
}

Scenario 6: Display the survey after X page visits

When to use this

Use this when users need repeated exposure to form an opinion. For example:

  • Usability feedback
  • Showing the survey once every 10 sessions to avoid disrupting the user flow

How to configure it

import '@opengovsg/confetti/confetti.css'

import { PopoverConfetti, useVisibleAfterPageVisits } from '@opengovsg/confetti'

const YourComponent = () => {
  // If no respondent is provided, page visits are tracked per device.
  // Otherwise, they are tracked per user per device.
  const { isVisible } = useVisibleAfterPageVisits({
    visits: 10,
    respondent: '<optional-user-id>',
  })

  return (
    <div style={{ position: 'fixed', bottom: '1rem', right: '1rem' }}>
      <PopoverConfetti
        surveyId="<your-survey-id>"
        publishableKey="<your-publishable-key>"
        isSurveyVisible={isVisible}
      />
    </div>
  )
}

Scenario 7: Display the survey to specific users only

When to use this

Use this when feedback is only relevant to certain users. For example:

  • Role-specific features — show the survey only to specific user types (e.g. admin, member)
  • Onboarding workflows — show the survey only once per user ID

How to configure it

import '@opengovsg/confetti/confetti.css'

import { PopoverConfetti } from '@opengovsg/confetti'

const YourComponent = () => {
  // Get the user role from your server or session.
  const { role, id: userId } = useUser()

  return (
    <div style={{ position: 'fixed', bottom: '1rem', right: '1rem' }}>
      <PopoverConfetti
        surveyId="<your-survey-id>"
        publishableKey="<your-publishable-key>"
        respondent={userId}
        // Show the survey only if the user's role is "superuser" and they
        // have not responded to this survey before.
        isSurveyVisible={({ lastRespondedAt }) =>
          role === 'superuser' && !lastRespondedAt
        }
      />
    </div>
  )
}

Scenario 8: Read a respondent's history yourself

When to use this

Use this when your own UI needs the respondent's response or dismissal times — for example, to choose between the survey and a thank-you message, or to combine Confetti history with other application state before mounting the widget.

Unlike drop-in templates, respondent is required here: the hook does not generate an anonymous identifier. Wait until you have a stable non-empty ID before calling it.

How to configure it

import '@opengovsg/confetti/confetti.css'

import { PopoverConfetti, useRespondentState } from '@opengovsg/confetti'

const YourComponent = () => {
  const { id: userId } = useUser()
  // Hooks cannot be called conditionally — render a child only once the
  // respondent id is known.
  if (!userId) return null
  return <RespondentHistory userId={userId} />
}

const RespondentHistory = ({ userId }) => {
  const respondentState = useRespondentState({
    surveyId: '<your-survey-id>',
    publishableKey: '<your-publishable-key>',
    respondent: userId,
    // Optional. Pass this when you serve Confetti through a proxy.
    // proxyUrl: 'https://confetti.your-domain.gov.sg',
  })

  // Still loading the availability probe or respondent state.
  if (respondentState === undefined) return null
  // Survey unavailable (missing, unauthorised, or the probe failed) or an
  // empty respondent id was passed.
  if (respondentState === null) return null

  const { lastRespondedAt } = respondentState

  if (lastRespondedAt) {
    return <p>Thanks — you already shared feedback for this survey.</p>
  }

  return (
    <div style={{ position: 'fixed', bottom: '1rem', right: '1rem' }}>
      <PopoverConfetti
        surveyId="<your-survey-id>"
        publishableKey="<your-publishable-key>"
        respondent={userId}
        // Prefer the widget's own predicate so it reuses the state it
        // already fetches, instead of piping values from useRespondentState.
        // Check both timestamps: completing the survey does not set
        // lastDismissedAt.
        isSurveyVisible={({ lastRespondedAt, lastDismissedAt }) =>
          !lastRespondedAt && !lastDismissedAt
        }
      />
    </div>
  )
}

useRespondentState returns:

  • undefined while the survey-availability check or respondent fetch is in flight
  • null when respondent is empty, or when the survey is not available (the availability check failed for any reason — missing, unauthorised, network error, and so on)
  • { lastRespondedAt, lastDismissedAt } as Date | null values when the survey is available for this respondent

Calling useRespondentState and mounting a drop-in template for the same survey fetches availability twice. The template also fetches respondent state when isSurveyVisible is a callback (or omitted); pass a boolean isSurveyVisible when you have already decided visibility so the template skips that second respondent fetch. Prefer this hook when you need the timestamps for your own UI; use isSurveyVisible on the template when you only need to gate the widget.

If you are building a custom client and need the raw REST endpoints instead, see Get respondent statistics in the public API. For widget cool-downs, stick with isSurveyVisible above.

Was this page helpful?

On this page